From ceeb2b64a87583871786e94e7a482e3002f59d96 Mon Sep 17 00:00:00 2001 From: Divam Date: Fri, 3 Apr 2026 16:32:46 +0900 Subject: [PATCH 01/40] SvDsoStore: Ingest RewardCouponV2, CalculateRewardsV2, ProcessRewardsV2 - Note that beneficiary is rewardParty (and not the provider) Signed-off-by: Divam --- .../splice/sv/store/SvDsoStore.scala | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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..0c980deb9c 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,31 @@ 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)), + 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, From f0d49cddb6f216f6a3de56ef4c22dcd73b7cf475 Mon Sep 17 00:00:00 2001 From: Divam Date: Thu, 28 May 2026 12:08:30 +0900 Subject: [PATCH 02/40] Use provider as rewardParty in dso-store Signed-off-by: Divam --- .../org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0c980deb9c..7ebe90a317 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 @@ -1269,7 +1269,7 @@ object SvDsoStore { DsoAcsStoreRowData( contract, rewardRound = Some(contract.payload.round.number), - rewardParty = Some(PartyId.tryFromProtoPrimitive(contract.payload.beneficiary)), + rewardParty = Some(PartyId.tryFromProtoPrimitive(contract.payload.provider)), rewardAmount = Some(contract.payload.amount), contractExpiresAt = Some(Timestamp.assertFromInstant(contract.payload.expiresAt)), ) From edede74dc7ce866cedbb86c153772e96cb01d80b Mon Sep 17 00:00:00 2001 From: Divam Date: Mon, 6 Apr 2026 09:09:46 +0000 Subject: [PATCH 03/40] Handle stale CRARC_StartProcessingRewardsV2 Signed-off-by: Divam --- .../delegatebased/ExecuteConfirmedActionTrigger.scala | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExecuteConfirmedActionTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExecuteConfirmedActionTrigger.scala index 874abe182a..dbed349496 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExecuteConfirmedActionTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExecuteConfirmedActionTrigger.scala @@ -29,9 +29,11 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.ansentrycont ANSRARC_CollectInitialEntryPayment, ANSRARC_RejectEntryInitialPayment, } +import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.rewardaccountingv2.CalculateRewardsV2 import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.amuletrules_actionrequiringconfirmation.{ CRARC_MiningRound_Archive, CRARC_MiningRound_StartIssuing, + CRARC_StartProcessingRewardsV2, } import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.dsorules_actionrequiringconfirmation.* import org.lfdecentralizedtrust.splice.config.Thresholds @@ -202,6 +204,15 @@ class ExecuteConfirmedActionTrigger( closedRoundCid, ) .map(_.isEmpty) + case startProcessingAction: CRARC_StartProcessingRewardsV2 => + val calculateRewardsCid = + startProcessingAction.amuletRules_StartProcessingRewardsV2Value.calculateRewardsCid + store.multiDomainAcsStore + .lookupContractByIdOnDomain(CalculateRewardsV2.COMPANION)( + confirmation.domain, + calculateRewardsCid, + ) + .map(_.isEmpty) case action => throw new UnsupportedOperationException( show"AmuletRules $action is not yet supported" From e96d847e0e57113bd7ac1c1e08482bd7d055cb70 Mon Sep 17 00:00:00 2001 From: Divam Date: Tue, 7 Apr 2026 16:41:17 +0900 Subject: [PATCH 04/40] Add listCalculateRewardsV2 Signed-off-by: Divam --- .../splice/sv/store/SvDsoStore.scala | 7 ++++++ .../splice/sv/store/db/DbSvDsoStore.scala | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+) 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 7ebe90a317..9a2f32b38f 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 @@ -544,6 +544,13 @@ trait SvDsoStore splice.round.SummarizingMiningRound, ]]] + def listCalculateRewardsV2( + limit: Limit = defaultLimit + )(implicit tc: TraceContext): Future[Seq[AssignedContract[ + splice.amulet.rewardaccountingv2.CalculateRewardsV2.ContractId, + splice.amulet.rewardaccountingv2.CalculateRewardsV2, + ]]] + /** All `ClosedMiningRound` contracts that should be confirmed to be archived. * * These are all `ClosedMiningRound` contracts for which diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala index 49b14ab967..9b8f0b59de 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala @@ -691,6 +691,31 @@ class DbSvDsoStore( limited = applyLimit("listOldestSummarizingMiningRounds", limit, result) } yield limited.map(assignedContractFromRow(SummarizingMiningRound.COMPANION)(_)) + override def listCalculateRewardsV2(limit: Limit = defaultLimit)(implicit + tc: TraceContext + ): Future[Seq[ + AssignedContract[ + splice.amulet.rewardaccountingv2.CalculateRewardsV2.ContractId, + splice.amulet.rewardaccountingv2.CalculateRewardsV2, + ] + ]] = + for { + result <- storage + .query( + selectFromAcsTableWithState( + DsoTables.acsTableName, + acsStoreId, + domainMigrationId, + splice.amulet.rewardaccountingv2.CalculateRewardsV2.COMPANION, + orderLimit = sql"""order by reward_round limit ${sqlLimit(limit)}""", + ), + "listCalculateRewardsV2", + ) + limited = applyLimit("listCalculateRewardsV2", limit, result) + } yield limited.map( + assignedContractFromRow(splice.amulet.rewardaccountingv2.CalculateRewardsV2.COMPANION)(_) + ) + override def lookupConfirmationByActionWithOffset( confirmer: PartyId, action: ActionRequiringConfirmation, From 01846ca0e22e30453cab0734e1285750cf0d2246 Mon Sep 17 00:00:00 2001 From: Divam Date: Wed, 22 Apr 2026 08:53:44 +0000 Subject: [PATCH 05/40] yaml: archive-dry-runs API Signed-off-by: Divam --- apps/sv/src/main/openapi/sv-internal.yaml | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/sv/src/main/openapi/sv-internal.yaml b/apps/sv/src/main/openapi/sv-internal.yaml index 285628d302..9328fb8b43 100644 --- a/apps/sv/src/main/openapi/sv-internal.yaml +++ b/apps/sv/src/main/openapi/sv-internal.yaml @@ -687,6 +687,26 @@ paths: "500": $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" + /v0/admin/reward-accounting-process/archive-dry-runs: + post: + tags: [ sv ] + x-jvm-package: sv_operator + operationId: "archiveDryRunRewardAccountingContracts" + description: | + Archive active `CalculateRewardsV2` and `ProcessRewardsV2` contracts + for the specified rounds if they have `dryRun` as `True`. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ArchiveDryRunRewardAccountingContractsRequest" + responses: + "200": + description: ok + "400": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/400" + components: schemas: ListOngoingValidatorOnboardingsResponse: @@ -1261,3 +1281,17 @@ components: type: array items: $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/schemas/Contract" + + ArchiveDryRunRewardAccountingContractsRequest: + type: object + required: + - rounds + properties: + rounds: + description: | + Round numbers whose dry-run `CalculateRewardsV2` and `ProcessRewardsV2` + contracts should be archived. + type: array + items: + type: integer + format: int64 From 51a88b72bda8265e276477584a76795aa9bce5db Mon Sep 17 00:00:00 2001 From: Divam Date: Wed, 22 Apr 2026 09:06:48 +0000 Subject: [PATCH 06/40] listDryRunRewardAccountingContractsByRounds Signed-off-by: Divam --- .../splice/sv/store/SvDsoStore.scala | 18 ++++++ .../splice/sv/store/db/DbSvDsoStore.scala | 59 +++++++++++++++++++ 2 files changed, 77 insertions(+) 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 9a2f32b38f..c485145e04 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 @@ -551,6 +551,24 @@ trait SvDsoStore splice.amulet.rewardaccountingv2.CalculateRewardsV2, ]]] + /** Returns the dry-run `CalculateRewardsV2` and `ProcessRewardsV2` contracts whose + * round number is in the given set. + */ + def listDryRunRewardAccountingContractsByRounds(rounds: Seq[Long])(implicit + tc: TraceContext + ): Future[ + ( + Seq[AssignedContract[ + splice.amulet.rewardaccountingv2.CalculateRewardsV2.ContractId, + splice.amulet.rewardaccountingv2.CalculateRewardsV2, + ]], + Seq[AssignedContract[ + splice.amulet.rewardaccountingv2.ProcessRewardsV2.ContractId, + splice.amulet.rewardaccountingv2.ProcessRewardsV2, + ]], + ) + ] + /** All `ClosedMiningRound` contracts that should be confirmed to be archived. * * These are all `ClosedMiningRound` contracts for which diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala index 9b8f0b59de..adcb4c26cd 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala @@ -716,6 +716,65 @@ class DbSvDsoStore( assignedContractFromRow(splice.amulet.rewardaccountingv2.CalculateRewardsV2.COMPANION)(_) ) + override def listDryRunRewardAccountingContractsByRounds(rounds: Seq[Long])(implicit + tc: TraceContext + ): Future[ + ( + Seq[AssignedContract[ + splice.amulet.rewardaccountingv2.CalculateRewardsV2.ContractId, + splice.amulet.rewardaccountingv2.CalculateRewardsV2, + ]], + Seq[AssignedContract[ + splice.amulet.rewardaccountingv2.ProcessRewardsV2.ContractId, + splice.amulet.rewardaccountingv2.ProcessRewardsV2, + ]], + ) + ] = { + if (rounds.isEmpty) + Future.successful((Seq.empty, Seq.empty)) + else { + val roundsClause = inClause("reward_round", rounds) + val calculateRewardsF = storage + .query( + selectFromAcsTableWithState( + DsoTables.acsTableName, + acsStoreId, + domainMigrationId, + splice.amulet.rewardaccountingv2.CalculateRewardsV2.COMPANION, + additionalWhere = (sql" and " ++ roundsClause).toActionBuilder, + ), + "listDryRunCalculateRewardsV2ByRounds", + ) + .map( + _.map( + assignedContractFromRow(splice.amulet.rewardaccountingv2.CalculateRewardsV2.COMPANION)( + _ + ) + ).filter(_.payload.dryRun) + ) + val processRewardsF = storage + .query( + selectFromAcsTableWithState( + DsoTables.acsTableName, + acsStoreId, + domainMigrationId, + splice.amulet.rewardaccountingv2.ProcessRewardsV2.COMPANION, + additionalWhere = (sql" and " ++ roundsClause).toActionBuilder, + ), + "listDryRunProcessRewardsV2ByRounds", + ) + .map( + _.map( + assignedContractFromRow(splice.amulet.rewardaccountingv2.ProcessRewardsV2.COMPANION)(_) + ).filter(_.payload.dryRun) + ) + for { + calculateRewards <- calculateRewardsF + processRewards <- processRewardsF + } yield (calculateRewards, processRewards) + } + } + override def lookupConfirmationByActionWithOffset( confirmer: PartyId, action: ActionRequiringConfirmation, From 145e263994446536edd1b4c68e83d12dc93256ba Mon Sep 17 00:00:00 2001 From: Divam Date: Wed, 22 Apr 2026 09:07:11 +0000 Subject: [PATCH 07/40] archiveDryRunRewardAccountingContracts Signed-off-by: Divam --- .../splice/sv/SvApp.scala | 49 +++++++++++++++++++ .../sv/admin/http/HttpSvOperatorHandler.scala | 18 +++++++ 2 files changed, 67 insertions(+) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/SvApp.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/SvApp.scala index b43925066c..50c6cd547d 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/SvApp.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/SvApp.scala @@ -995,6 +995,55 @@ object SvApp { } } + def archiveDryRunRewardAccountingContracts( + rounds: Seq[Long], + dsoStoreWithIngestion: AppStoreWithIngestion[SvDsoStore], + retryProvider: RetryProvider, + logger: TracedLogger, + )(implicit + ec: ExecutionContext, + traceContext: TraceContext, + ): Future[Unit] = { + val store = dsoStoreWithIngestion.store + store.listDryRunRewardAccountingContractsByRounds(rounds).flatMap { + case (calculateRewards, processRewards) => + if (calculateRewards.isEmpty && processRewards.isEmpty) { + Future.unit + } else { + retryProvider.retryForClientCalls( + "archiveDryRunRewardAccountingContracts", + "archiveDryRunRewardAccountingContracts", + for { + dsoRules <- store.getDsoRules() + amuletRules <- store.getAmuletRules() + choiceArg = + new splice.amuletrules.AmuletRules_ArchiveDryRunRewardAccountingV2( + calculateRewards.map(_.contractId).asJava, + processRewards.map(_.contractId).asJava, + ) + cmd = dsoRules.exercise( + _.exerciseDsoRules_ArchiveDryRunRewardAccountingV2( + amuletRules.contractId, + choiceArg, + store.key.svParty.toProtoPrimitive, + ) + ) + _ <- dsoStoreWithIngestion + .connection(SpliceLedgerConnectionPriority.Low) + .submit( + actAs = Seq(store.key.svParty), + readAs = Seq(store.key.dsoParty), + update = cmd, + ) + .noDedup + .yieldUnit() + } yield (), + logger, + ) + } + } + } + def castVote( trackingCid: splice.dsorules.VoteRequest.ContractId, isAccepted: Boolean, diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala index 77ab3ae296..e500ef53ed 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala @@ -350,6 +350,24 @@ class HttpSvOperatorHandler( .map(r0.LookupDsoRulesVoteRequestResponse.OK) } + override def archiveDryRunRewardAccountingContracts( + respond: r0.ArchiveDryRunRewardAccountingContractsResponse.type + )( + body: definitions.ArchiveDryRunRewardAccountingContractsRequest + )(extracted: ActAsKnownUserRequest): Future[r0.ArchiveDryRunRewardAccountingContractsResponse] = { + implicit val ActAsKnownUserRequest(traceContext) = extracted + withSpan(s"$workflowId.archiveDryRunRewardAccountingContracts") { _ => _ => + SvApp + .archiveDryRunRewardAccountingContracts( + body.rounds.toSeq, + dsoStoreWithIngestion, + retryProvider, + logger, + ) + .map(_ => r0.ArchiveDryRunRewardAccountingContractsResponseOK) + } + } + override def castVote(respond: r0.CastVoteResponse.type)( body: definitions.CastVoteRequest )(extracted: ActAsKnownUserRequest): Future[r0.CastVoteResponse] = { From 9771b7a20725d6cbe2c3011f46a57aa825299b99 Mon Sep 17 00:00:00 2001 From: Divam Date: Fri, 24 Apr 2026 06:19:56 +0000 Subject: [PATCH 08/40] sv-app archiveDryRunRewardAccountingContracts Signed-off-by: Divam --- .../splice/console/SvAppReference.scala | 10 +++++++++ .../commands/HttpSvOperatorAppClient.scala | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/SvAppReference.scala b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/SvAppReference.scala index 4a1ef1b8c3..7cf2d75fd8 100644 --- a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/SvAppReference.scala +++ b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/SvAppReference.scala @@ -395,6 +395,16 @@ class SvAppBackendReference( } } + @Help.Summary( + "Archive dry-run CalculateRewardsV2 and ProcessRewardsV2 contracts for the given rounds (via admin API)" + ) + def archiveDryRunRewardAccountingContracts(rounds: Seq[Long]): Unit = + consoleEnvironment.run { + httpCommand( + HttpSvOperatorAppClient.ArchiveDryRunRewardAccountingContracts(rounds) + ) + } + @Help.Summary("Get the CometBFT node debug dump") def cometBftNodeDump(): definitions.CometBftNodeDumpResponse = consoleEnvironment.run { diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvOperatorAppClient.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvOperatorAppClient.scala index cd1181cb30..b9f300eebf 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvOperatorAppClient.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvOperatorAppClient.scala @@ -365,6 +365,28 @@ object HttpSvOperatorAppClient { } } + case class ArchiveDryRunRewardAccountingContracts(rounds: Seq[Long]) + extends BaseCommand[http.ArchiveDryRunRewardAccountingContractsResponse, Unit] { + + override def submitRequest( + client: Client, + headers: List[HttpHeader], + ): EitherT[Future, Either[ + Throwable, + HttpResponse, + ], http.ArchiveDryRunRewardAccountingContractsResponse] = + client.archiveDryRunRewardAccountingContracts( + body = definitions.ArchiveDryRunRewardAccountingContractsRequest(rounds.toVector), + headers = headers, + ) + + override def handleOk()(implicit + decoder: TemplateJsonDecoder + ) = { case http.ArchiveDryRunRewardAccountingContractsResponse.OK => + Right(()) + } + } + case class GetPartyToParticipant(partyId: String) extends BaseCommand[ http.GetPartyToParticipantResponse, From 538a8db92338b0ba0d295e9508002aef24e01184 Mon Sep 17 00:00:00 2001 From: Divam Date: Thu, 30 Apr 2026 04:30:04 +0000 Subject: [PATCH 09/40] SvdsoStore: add listProcessRewardsV2 and listRewardCouponV2 Signed-off-by: Divam --- .../splice/sv/store/SvDsoStore.scala | 14 ++++++ .../splice/sv/store/db/DbSvDsoStore.scala | 50 +++++++++++++++++++ 2 files changed, 64 insertions(+) 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 c485145e04..038034dc78 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 @@ -551,6 +551,20 @@ trait SvDsoStore splice.amulet.rewardaccountingv2.CalculateRewardsV2, ]]] + def listProcessRewardsV2( + limit: Limit = defaultLimit + )(implicit tc: TraceContext): Future[Seq[AssignedContract[ + splice.amulet.rewardaccountingv2.ProcessRewardsV2.ContractId, + splice.amulet.rewardaccountingv2.ProcessRewardsV2, + ]]] + + def listRewardCouponsV2( + limit: Limit = defaultLimit + )(implicit tc: TraceContext): Future[Seq[AssignedContract[ + splice.amulet.RewardCouponV2.ContractId, + splice.amulet.RewardCouponV2, + ]]] + /** Returns the dry-run `CalculateRewardsV2` and `ProcessRewardsV2` contracts whose * round number is in the given set. */ diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala index adcb4c26cd..6ab4f84d6d 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala @@ -716,6 +716,56 @@ class DbSvDsoStore( assignedContractFromRow(splice.amulet.rewardaccountingv2.CalculateRewardsV2.COMPANION)(_) ) + override def listProcessRewardsV2(limit: Limit = defaultLimit)(implicit + tc: TraceContext + ): Future[Seq[ + AssignedContract[ + splice.amulet.rewardaccountingv2.ProcessRewardsV2.ContractId, + splice.amulet.rewardaccountingv2.ProcessRewardsV2, + ] + ]] = + for { + result <- storage + .query( + selectFromAcsTableWithState( + DsoTables.acsTableName, + acsStoreId, + domainMigrationId, + splice.amulet.rewardaccountingv2.ProcessRewardsV2.COMPANION, + orderLimit = sql"""order by reward_round limit ${sqlLimit(limit)}""", + ), + "listProcessRewardsV2", + ) + limited = applyLimit("listProcessRewardsV2", limit, result) + } yield limited.map( + assignedContractFromRow(splice.amulet.rewardaccountingv2.ProcessRewardsV2.COMPANION)(_) + ) + + override def listRewardCouponsV2(limit: Limit = defaultLimit)(implicit + tc: TraceContext + ): Future[Seq[ + AssignedContract[ + splice.amulet.RewardCouponV2.ContractId, + splice.amulet.RewardCouponV2, + ] + ]] = + for { + result <- storage + .query( + selectFromAcsTableWithState( + DsoTables.acsTableName, + acsStoreId, + domainMigrationId, + splice.amulet.RewardCouponV2.COMPANION, + orderLimit = sql"""order by reward_round limit ${sqlLimit(limit)}""", + ), + "listRewardCouponsV2", + ) + limited = applyLimit("listRewardCouponsV2", limit, result) + } yield limited.map( + assignedContractFromRow(splice.amulet.RewardCouponV2.COMPANION)(_) + ) + override def listDryRunRewardAccountingContractsByRounds(rounds: Seq[Long])(implicit tc: TraceContext ): Future[ From db48d3aa0ebbdd809d88ec9fc2183384e464eb7c Mon Sep 17 00:00:00 2001 From: Divam Date: Tue, 7 Apr 2026 16:41:46 +0900 Subject: [PATCH 10/40] Add CalculateRewardsTrigger Signed-off-by: Divam --- .../automation/SvDsoAutomationService.scala | 22 ++ .../CalculateRewardsTrigger.scala | 219 ++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala index 65cd69338d..9fa5d58b5b 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala @@ -364,6 +364,26 @@ class SvDsoAutomationService( ) ) + registerTrigger( + new CalculateRewardsTrigger( + triggerContext, + dsoStore, + connection(SpliceLedgerConnectionPriority.Medium), + config.scan, + upgradesConfig, + ) + ) + + registerTrigger( + new CalculateRewardsDryRunTrigger( + triggerContext, + dsoStore, + connection(SpliceLedgerConnectionPriority.Medium), + config.scan, + upgradesConfig, + ) + ) + registerTrigger(restartDsoDelegateBasedAutomationTrigger) registerTrigger( @@ -554,6 +574,8 @@ object SvDsoAutomationService extends AutomationServiceCompanion { aTrigger[SvOnboardingRequestTrigger], aTrigger[ReceiveSvRewardCouponTrigger], aTrigger[ArchiveClosedMiningRoundsTrigger], + aTrigger[CalculateRewardsTrigger], + aTrigger[CalculateRewardsDryRunTrigger], aTrigger[RestartDsoDelegateBasedAutomationTrigger], aTrigger[AnsSubscriptionInitialPaymentTrigger], aTrigger[SvPackageVettingTrigger], diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala new file mode 100644 index 0000000000..52571e1689 --- /dev/null +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala @@ -0,0 +1,219 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.sv.automation.confirmation + +import org.apache.pekko.stream.Materializer +import org.lfdecentralizedtrust.splice.automation.{ + PollingParallelTaskExecutionTrigger, + TaskOutcome, + TaskSuccess, + TriggerContext, +} +import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.cryptohash.Hash +import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.rewardaccountingv2.CalculateRewardsV2 +import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.AmuletRules_StartProcessingRewardsV2 +import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.ActionRequiringConfirmation +import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.actionrequiringconfirmation.ARC_AmuletRules +import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.amuletrules_actionrequiringconfirmation.CRARC_StartProcessingRewardsV2 +import org.lfdecentralizedtrust.splice.config.{NetworkAppClientConfig, UpgradesConfig} +import org.lfdecentralizedtrust.splice.environment.SpliceLedgerConnection +import org.lfdecentralizedtrust.splice.http.HttpClient +import org.lfdecentralizedtrust.splice.scan.admin.api.client.ScanConnection +import org.lfdecentralizedtrust.splice.scan.config.ScanAppClientConfig +import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.QueryResult +import org.lfdecentralizedtrust.splice.sv.config.SvScanConfig +import org.lfdecentralizedtrust.splice.sv.store.SvDsoStore +import org.lfdecentralizedtrust.splice.util.{AssignedContract, TemplateJsonDecoder} +import org.lfdecentralizedtrust.splice.util.PrettyInstances.* +import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} +import com.digitalasset.canton.tracing.TraceContext +import io.opentelemetry.api.trace.Tracer + +import scala.concurrent.{ExecutionContextExecutor, Future} +import scala.util.{Failure, Success} + +abstract class CalculateRewardsTriggerBase( + override protected val context: TriggerContext, + store: SvDsoStore, + connection: SpliceLedgerConnection, + scanConfig: SvScanConfig, + upgradesConfig: UpgradesConfig, + isDryRun: Boolean, +)(implicit + ec: ExecutionContextExecutor, + mat: Materializer, + tracer: Tracer, + httpClient: HttpClient, + templateJsonDecoder: TemplateJsonDecoder, +) extends PollingParallelTaskExecutionTrigger[CalculateRewardsTriggerBase.Task] { + + import CalculateRewardsTriggerBase.* + + private val svParty = store.key.svParty + private val dsoParty = store.key.dsoParty + + override def retrieveTasks()(implicit tc: TraceContext): Future[Seq[Task]] = for { + calculateRewards <- store.listCalculateRewardsV2() + } yield calculateRewards.filter(_.payload.dryRun == isDryRun).map(Task(_)) + + override def completeTask( + task: Task + )(implicit tc: TraceContext): Future[TaskOutcome] = { + val round = task.calculateRewards.payload.round.number + for { + rootHash <- getRootHash(round) + action = startProcessingRewardsAction( + task.calculateRewards.contractId, + rootHash, + ) + queryResult <- store.lookupConfirmationByActionWithOffset(svParty, action) + taskOutcome <- queryResult match { + case QueryResult(_, Some(_)) => + Future.successful( + TaskSuccess( + s"skipping as confirmation from $svParty is already created for CalculateRewardsV2 round $round" + ) + ) + case QueryResult(offset, None) => + for { + dsoRules <- store.getDsoRules() + cmd = dsoRules.exercise( + _.exerciseDsoRules_ConfirmAction( + svParty.toProtoPrimitive, + action, + ) + ) + _ <- connection + .submit( + actAs = Seq(svParty), + readAs = Seq(dsoParty), + update = cmd, + ) + .withDedup( + commandId = SpliceLedgerConnection.CommandId( + "org.lfdecentralizedtrust.splice.sv.createStartProcessingRewardsV2Confirmation", + Seq(svParty, dsoParty), + task.calculateRewards.contractId.contractId, + ), + deduplicationOffset = offset, + ) + .yieldUnit() + } yield TaskSuccess( + s"created confirmation for CalculateRewardsV2 round $round" + ) + } + } yield taskOutcome + } + + override def isStaleTask(task: Task)(implicit + tc: TraceContext + ): Future[Boolean] = + store.multiDomainAcsStore + .lookupContractById(CalculateRewardsV2.COMPANION)(task.calculateRewards.contractId) + .map(_.isEmpty) + + private def withScanConnection[T](f: ScanConnection => Future[T])(implicit + tc: TraceContext + ): Future[T] = + ScanConnection + .singleUncached( + ScanAppClientConfig(NetworkAppClientConfig(scanConfig.internalUrl)), + upgradesConfig, + context.clock, + context.retryProvider, + loggerFactory, + retryConnectionOnInitialFailure = false, + ) + .transformWith { + case Failure(ex) => + Future.failed( + new RuntimeException("Failed to connect to scan for root hash lookup", ex) + ) + case Success(conn) => + f(conn) + } + + private def getRootHash(round: Long)(implicit tc: TraceContext): Future[Hash] = + withScanConnection { conn => + conn.getRewardAccountingRootHash(round).map { + case Some(response) => new Hash(response.rootHash) + case None => + throw new RuntimeException( + s"Root hash not available from scan for round $round" + ) + } + } + + private def startProcessingRewardsAction( + calculateRewardsCid: CalculateRewardsV2.ContractId, + rootHash: Hash, + ): ActionRequiringConfirmation = + new ARC_AmuletRules( + new CRARC_StartProcessingRewardsV2( + new AmuletRules_StartProcessingRewardsV2( + calculateRewardsCid, + rootHash, + ) + ) + ) + +} + +class CalculateRewardsTrigger( + override protected val context: TriggerContext, + store: SvDsoStore, + connection: SpliceLedgerConnection, + scanConfig: SvScanConfig, + upgradesConfig: UpgradesConfig, +)(implicit + ec: ExecutionContextExecutor, + mat: Materializer, + tracer: Tracer, + httpClient: HttpClient, + templateJsonDecoder: TemplateJsonDecoder, +) extends CalculateRewardsTriggerBase( + context, + store, + connection, + scanConfig, + upgradesConfig, + isDryRun = false, + ) + +class CalculateRewardsDryRunTrigger( + override protected val context: TriggerContext, + store: SvDsoStore, + connection: SpliceLedgerConnection, + scanConfig: SvScanConfig, + upgradesConfig: UpgradesConfig, +)(implicit + ec: ExecutionContextExecutor, + mat: Materializer, + tracer: Tracer, + httpClient: HttpClient, + templateJsonDecoder: TemplateJsonDecoder, +) extends CalculateRewardsTriggerBase( + context, + store, + connection, + scanConfig, + upgradesConfig, + isDryRun = true, + ) + +object CalculateRewardsTriggerBase { + + final case class Task( + calculateRewards: AssignedContract[ + CalculateRewardsV2.ContractId, + CalculateRewardsV2, + ] + ) extends PrettyPrinting { + override def pretty: Pretty[this.type] = + prettyOfClass( + param("round", _.calculateRewards.payload.round.number), + param("dryRun", _.calculateRewards.payload.dryRun.toString.unquoted), + ) + } +} From b1064ef599c8ba27dc0950a0a4ef4f0621ec021c Mon Sep 17 00:00:00 2001 From: Divam Date: Thu, 9 Apr 2026 16:22:15 +0900 Subject: [PATCH 11/40] ProcessRewardsTrigger init Signed-off-by: Divam --- .../DsoDelegateBasedAutomationService.scala | 22 +- .../automation/SvDsoAutomationService.scala | 1 + .../delegatebased/ProcessRewardsTrigger.scala | 211 ++++++++++++++++++ ...artDsoDelegateBasedAutomationTrigger.scala | 16 +- 4 files changed, 242 insertions(+), 8 deletions(-) create mode 100644 apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/DsoDelegateBasedAutomationService.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/DsoDelegateBasedAutomationService.scala index 6b924f3bd7..6e35409e6d 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/DsoDelegateBasedAutomationService.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/DsoDelegateBasedAutomationService.scala @@ -17,11 +17,14 @@ import org.lfdecentralizedtrust.splice.store.{ DomainTimeSynchronization, DomainUnpausedSynchronization, } +import org.lfdecentralizedtrust.splice.config.UpgradesConfig +import org.lfdecentralizedtrust.splice.http.HttpClient import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.* -import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredAmuletAllocationTrigger +import org.lfdecentralizedtrust.splice.sv.config.{SvAppBackendConfig, SvScanConfig} +import org.lfdecentralizedtrust.splice.util.TemplateJsonDecoder -import scala.concurrent.ExecutionContext +import scala.concurrent.ExecutionContextExecutor class DsoDelegateBasedAutomationService( clock: Clock, @@ -29,12 +32,16 @@ class DsoDelegateBasedAutomationService( domainUnpausedSync: DomainUnpausedSynchronization, config: SvAppBackendConfig, svTaskContext: SvTaskBasedTrigger.Context, + scanConfig: SvScanConfig, + upgradesConfig: UpgradesConfig, retryProvider: RetryProvider, override protected val loggerFactory: NamedLoggerFactory, )(implicit - ec: ExecutionContext, + ec: ExecutionContextExecutor, mat: Materializer, tracer: Tracer, + httpClient: HttpClient, + templateJsonDecoder: TemplateJsonDecoder, ) extends AutomationService( config.automation, clock, @@ -140,6 +147,13 @@ class DsoDelegateBasedAutomationService( svTaskContext, ) ) + + registerTrigger( + new ProcessRewardsTrigger(triggerContext, svTaskContext, scanConfig, upgradesConfig) + ) + registerTrigger( + new ProcessRewardsDryRunTrigger(triggerContext, svTaskContext, scanConfig, upgradesConfig) + ) } } @@ -179,5 +193,7 @@ object DsoDelegateBasedAutomationService extends AutomationServiceCompanion { aTrigger[MergeUnclaimedDevelopmentFundCouponsTrigger], aTrigger[ExpiredDevelopmentFundCouponTrigger], aTrigger[BootstrapExternalPartyConfigStateInstructionTrigger], + aTrigger[ProcessRewardsTrigger], + aTrigger[ProcessRewardsDryRunTrigger], ) } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala index 9fa5d58b5b..6a4c2e3eb8 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala @@ -128,6 +128,7 @@ class SvDsoAutomationService( retryProvider, packageVersionSupport, packageVettingService, + upgradesConfig, ) // required for triggers that must run in sim time as well diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala new file mode 100644 index 0000000000..36221f9524 --- /dev/null +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala @@ -0,0 +1,211 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.sv.automation.delegatebased + +import org.apache.pekko.stream.Materializer +import org.lfdecentralizedtrust.splice.automation.{ + OnAssignedContractTrigger, + TaskOutcome, + TaskSuccess, + TriggerContext, +} +import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.cryptohash.Hash +import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.rewardaccountingv2.{ + MintingAllowance, + ProcessRewardsV2, + ProcessRewardsV2_ProcessBatch, +} +import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.rewardaccountingv2.batch.{ + BatchOfBatches, + BatchOfMintingAllowances, +} +import org.lfdecentralizedtrust.splice.config.{NetworkAppClientConfig, UpgradesConfig} +import org.lfdecentralizedtrust.splice.http.HttpClient +import org.lfdecentralizedtrust.splice.http.v0.definitions.{ + GetRewardAccountingBatchResponse, + RewardAccountingMintingAllowance, +} +import org.lfdecentralizedtrust.splice.scan.admin.api.client.ScanConnection +import org.lfdecentralizedtrust.splice.scan.config.ScanAppClientConfig +import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority +import org.lfdecentralizedtrust.splice.sv.config.SvScanConfig +import org.lfdecentralizedtrust.splice.util.{AssignedContract, TemplateJsonDecoder} +import com.digitalasset.canton.tracing.TraceContext +import io.opentelemetry.api.trace.Tracer +import org.lfdecentralizedtrust.splice.codegen.java.da.set.types.{Set as DamlSet} + +import java.math.BigDecimal +import scala.concurrent.{ExecutionContextExecutor, Future} +import scala.jdk.CollectionConverters.* +import scala.util.{Failure, Success} + +import ProcessRewardsTriggerBase.* + +private[delegatebased] abstract class ProcessRewardsTriggerBase( + override protected val context: TriggerContext, + override protected val svTaskContext: SvTaskBasedTrigger.Context, + scanConfig: SvScanConfig, + upgradesConfig: UpgradesConfig, + isDryRun: Boolean, +)(implicit + ec: ExecutionContextExecutor, + mat: Materializer, + tracer: Tracer, + httpClient: HttpClient, + templateJsonDecoder: TemplateJsonDecoder, +) extends OnAssignedContractTrigger.Template[ + ProcessRewardsV2.ContractId, + ProcessRewardsV2, + ]( + svTaskContext.dsoStore, + ProcessRewardsV2.COMPANION, + ) + with SvTaskBasedTrigger[ProcessRewardsV2Contract] { + + private val store = svTaskContext.dsoStore + + override def completeTaskAsDsoDelegate( + task: ProcessRewardsV2Contract, + controller: String, + )(implicit tc: TraceContext): Future[TaskOutcome] = { + if (task.payload.dryRun != isDryRun) { + Future.successful( + TaskSuccess( + s"Skipping ProcessRewardsV2 for round ${task.payload.round.number} with dryRun=${task.payload.dryRun}" + ) + ) + } else { + val round = task.payload.round.number + val batchHash = task.payload.batchHash.value + val batchF = fetchBatch(round, batchHash) + val dsoRulesF = store.getDsoRules() + for { + batch <- batchF + dsoRules <- dsoRulesF + damlBatch = convertBatch(batch) + choiceArg = new ProcessRewardsV2_ProcessBatch( + damlBatch, + new DamlSet(java.util.Collections.emptyMap()), + ) + cmd = dsoRules.exercise( + _.exerciseDsoRules_ProcessRewardsV2_ProcessBatch( + task.contractId, + choiceArg, + controller, + ) + ) + _ <- svTaskContext + .connection(SpliceLedgerConnectionPriority.Low) + .submit( + Seq(store.key.svParty), + Seq(store.key.dsoParty), + cmd, + ) + .noDedup + .yieldUnit() + } yield TaskSuccess( + s"Processed batch for ProcessRewardsV2 round $round, batchHash=$batchHash, dryRun=$isDryRun" + ) + } + } + + private def convertBatch( + response: GetRewardAccountingBatchResponse + ): org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.rewardaccountingv2.Batch = + response match { + case GetRewardAccountingBatchResponse.members.RewardAccountingBatchOfBatches(value) => + val childHashes = value.childHashes.map(h => new Hash(h)).asJava + new BatchOfBatches(childHashes) + case GetRewardAccountingBatchResponse.members.RewardAccountingBatchOfMintingAllowances( + value + ) => + val allowances = value.mintingAllowances + .map((a: RewardAccountingMintingAllowance) => + new MintingAllowance(a.provider, new BigDecimal(a.amount)) + ) + .asJava + new BatchOfMintingAllowances(allowances) + } + + private def fetchBatch(round: Long, batchHash: String)(implicit + tc: TraceContext + ): Future[GetRewardAccountingBatchResponse] = + withScanConnection { conn => + conn.getRewardAccountingBatch(round, batchHash).map { + case Some(response) => response + case None => + throw new RuntimeException( + s"Batch not found from scan for round $round with hash $batchHash" + ) + } + } + + private def withScanConnection[T](f: ScanConnection => Future[T])(implicit + tc: TraceContext + ): Future[T] = + ScanConnection + .singleUncached( + ScanAppClientConfig( + NetworkAppClientConfig(scanConfig.internalUrl) + ), + upgradesConfig, + context.clock, + context.retryProvider, + loggerFactory, + retryConnectionOnInitialFailure = false, + ) + .transformWith { + case Failure(ex) => + Future.failed( + new RuntimeException("Failed to connect to scan for batch lookup", ex) + ) + case Success(conn) => + f(conn) + } +} + +class ProcessRewardsTrigger( + override protected val context: TriggerContext, + override protected val svTaskContext: SvTaskBasedTrigger.Context, + scanConfig: SvScanConfig, + upgradesConfig: UpgradesConfig, +)(implicit + ec: ExecutionContextExecutor, + mat: Materializer, + tracer: Tracer, + httpClient: HttpClient, + templateJsonDecoder: TemplateJsonDecoder, +) extends ProcessRewardsTriggerBase( + context, + svTaskContext, + scanConfig, + upgradesConfig, + isDryRun = false, + ) + +class ProcessRewardsDryRunTrigger( + override protected val context: TriggerContext, + override protected val svTaskContext: SvTaskBasedTrigger.Context, + scanConfig: SvScanConfig, + upgradesConfig: UpgradesConfig, +)(implicit + ec: ExecutionContextExecutor, + mat: Materializer, + tracer: Tracer, + httpClient: HttpClient, + templateJsonDecoder: TemplateJsonDecoder, +) extends ProcessRewardsTriggerBase( + context, + svTaskContext, + scanConfig, + upgradesConfig, + isDryRun = true, + ) + +private[delegatebased] object ProcessRewardsTriggerBase { + type ProcessRewardsV2Contract = AssignedContract[ + ProcessRewardsV2.ContractId, + ProcessRewardsV2, + ] +} diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/RestartDsoDelegateBasedAutomationTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/RestartDsoDelegateBasedAutomationTrigger.scala index 63aaef9920..cb5f3da5f9 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/RestartDsoDelegateBasedAutomationTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/RestartDsoDelegateBasedAutomationTrigger.scala @@ -11,12 +11,15 @@ import org.lfdecentralizedtrust.splice.automation.{ TriggerContext, } import org.lfdecentralizedtrust.splice.codegen.java.splice +import org.lfdecentralizedtrust.splice.config.UpgradesConfig import org.lfdecentralizedtrust.splice.environment.{ PackageVersionSupport, PackageVettingLookupService, RetryProvider, SpliceLedgerConnection, } +import org.lfdecentralizedtrust.splice.http.HttpClient +import org.lfdecentralizedtrust.splice.util.TemplateJsonDecoder import org.lfdecentralizedtrust.splice.store.{ DomainTimeSynchronization, DomainUnpausedSynchronization, @@ -30,7 +33,7 @@ import com.digitalasset.canton.time.Clock import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer -import scala.concurrent.{ExecutionContext, Future, blocking} +import scala.concurrent.{ExecutionContextExecutor, Future, blocking} import com.digitalasset.canton.lifecycle.RunOnClosing import com.digitalasset.canton.lifecycle.AsyncOrSyncCloseable import com.digitalasset.canton.lifecycle.SyncCloseable @@ -50,10 +53,13 @@ class RestartDsoDelegateBasedAutomationTrigger( appLevelRetryProvider: RetryProvider, packageVersionSupport: PackageVersionSupport, packageVettingService: PackageVettingLookupService, + upgradesConfig: UpgradesConfig, )(implicit - override val ec: ExecutionContext, + override val ec: ExecutionContextExecutor, mat: Materializer, tracer: Tracer, + httpClient: HttpClient, + templateJsonDecoder: TemplateJsonDecoder, ) extends OnAssignedContractTrigger.Template[ splice.dsorules.DsoRules.ContractId, splice.dsorules.DsoRules, @@ -129,9 +135,7 @@ class RestartDsoDelegateBasedAutomationTrigger( } } - private def restartAutomation(epoch: Long)(implicit - ec: ExecutionContext - ): TaskOutcome = { + private def restartAutomation(epoch: Long): TaskOutcome = { val svTaskContext = SvTaskBasedTrigger.Context( store, @@ -164,6 +168,8 @@ class RestartDsoDelegateBasedAutomationTrigger( domainUnpausedSync, config, svTaskContext, + config.scan, + upgradesConfig, retryProvider, loggerFactory, ) From 51826dff2fe1cd76869d90bb95f95918157ac57d Mon Sep 17 00:00:00 2001 From: Divam Date: Mon, 27 Apr 2026 05:24:36 +0000 Subject: [PATCH 12/40] scalatesttag SpliceAmulet_0_1_19 Signed-off-by: Divam --- .../util/scalatesttags/SpliceAmulet_0_1_19.java | 14 ++++++++++++++ .../splice/util/ScalaTestTags.scala | 3 +++ 2 files changed, 17 insertions(+) create mode 100644 apps/common/src/test/java/org/lfdecentralizedtrust/splice/util/scalatesttags/SpliceAmulet_0_1_19.java diff --git a/apps/common/src/test/java/org/lfdecentralizedtrust/splice/util/scalatesttags/SpliceAmulet_0_1_19.java b/apps/common/src/test/java/org/lfdecentralizedtrust/splice/util/scalatesttags/SpliceAmulet_0_1_19.java new file mode 100644 index 0000000000..a89bc1645a --- /dev/null +++ b/apps/common/src/test/java/org/lfdecentralizedtrust/splice/util/scalatesttags/SpliceAmulet_0_1_19.java @@ -0,0 +1,14 @@ +package org.lfdecentralizedtrust.splice.util.scalatesttags; + +import org.scalatest.TagAnnotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +// Don't run this test when testing against splice-amulet < 0.1.19 +@TagAnnotation +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE}) +public @interface SpliceAmulet_0_1_19 {} diff --git a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/util/ScalaTestTags.scala b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/util/ScalaTestTags.scala index 71a400af25..8f6f0f7af5 100644 --- a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/util/ScalaTestTags.scala +++ b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/util/ScalaTestTags.scala @@ -15,4 +15,7 @@ object Tags { // Don't run this test when testing against splice-amulet < 0.1.17 object SpliceAmulet_0_1_17 extends Tag("org.lfdecentralizedtrust.splice.util.scalatesttags.SpliceAmulet_0_1_17") + // Don't run this test when testing against splice-amulet < 0.1.19 + object SpliceAmulet_0_1_19 + extends Tag("org.lfdecentralizedtrust.splice.util.scalatesttags.SpliceAmulet_0_1_19") } From 92b6f36c1f6a663ee7c482bfabf0574408fc5850 Mon Sep 17 00:00:00 2001 From: Divam Date: Wed, 6 May 2026 14:43:01 +0900 Subject: [PATCH 13/40] RewardProcessingMetrics init Signed-off-by: Divam --- .../splice/metrics/MetricsDocs.scala | 2 ++ .../automation/RewardProcessingMetrics.scala | 36 +++++++++++++++++++ .../CalculateRewardsTrigger.scala | 12 ++++++- .../delegatebased/ProcessRewardsTrigger.scala | 10 +++++- 4 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/RewardProcessingMetrics.scala diff --git a/apps/metrics-docs/src/main/scala/org/lfdecentralizedtrust/splice/metrics/MetricsDocs.scala b/apps/metrics-docs/src/main/scala/org/lfdecentralizedtrust/splice/metrics/MetricsDocs.scala index b253c925b4..2da6f55c17 100644 --- a/apps/metrics-docs/src/main/scala/org/lfdecentralizedtrust/splice/metrics/MetricsDocs.scala +++ b/apps/metrics-docs/src/main/scala/org/lfdecentralizedtrust/splice/metrics/MetricsDocs.scala @@ -18,6 +18,7 @@ import org.lfdecentralizedtrust.splice.sv.automation.singlesv.SequencerPruningMe import org.lfdecentralizedtrust.splice.sv.automation.{ AmuletPriceMetricsTrigger, ReportSvStatusMetricsExportTrigger, + RewardProcessingMetrics, } import org.lfdecentralizedtrust.splice.sv.store.db.DbSvDsoStoreMetrics import org.lfdecentralizedtrust.splice.store.{DomainParamsStore, HistoryMetrics, StoreMetrics} @@ -103,6 +104,7 @@ object MetricsDocs { generator, ) new AmuletPriceMetricsTrigger.AmuletPriceMetrics(generator) + new RewardProcessingMetrics(generator) val svMetrics = generator.getAll() generator.reset() // scan diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/RewardProcessingMetrics.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/RewardProcessingMetrics.scala new file mode 100644 index 0000000000..4f03e36a69 --- /dev/null +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/RewardProcessingMetrics.scala @@ -0,0 +1,36 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.sv.automation + +import com.daml.metrics.api.{MetricInfo, MetricName} +import com.daml.metrics.api.MetricHandle.{LabeledMetricsFactory, Timer} +import com.daml.metrics.api.MetricQualification.Latency +import org.lfdecentralizedtrust.splice.environment.SpliceMetrics + +class RewardProcessingMetrics(metricsFactory: LabeledMetricsFactory) { + + private val prefix: MetricName = SpliceMetrics.MetricsPrefix + + val calculateRewardsProcessingDelay: Timer = + metricsFactory.timer( + MetricInfo( + name = prefix :+ "calculate_rewards_v2" :+ "processing_delay", + summary = "Delay between round close and CalculateRewardsV2 confirmation creation", + description = + "This metric captures the time it took between the closing of a round, and this SV's confirmation for the CalculateRewardsV2 contract's processing. Labeled with dryRun.", + qualification = Latency, + ) + ) + + val processRewardsProcessingDelay: Timer = + metricsFactory.timer( + MetricInfo( + name = prefix :+ "process_rewards_v2" :+ "processing_delay", + summary = "Delay between round close and ProcessRewardsV2 processing", + description = + "This metric captures the time it took between the closing of a round, and this SV's processing of a ProcessRewardsV2 contract for that round. Labeled with dryRun.", + qualification = Latency, + ) + ) +} diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala index 52571e1689..650b606d33 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala @@ -22,10 +22,12 @@ import org.lfdecentralizedtrust.splice.http.HttpClient import org.lfdecentralizedtrust.splice.scan.admin.api.client.ScanConnection import org.lfdecentralizedtrust.splice.scan.config.ScanAppClientConfig import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.QueryResult +import org.lfdecentralizedtrust.splice.sv.automation.RewardProcessingMetrics import org.lfdecentralizedtrust.splice.sv.config.SvScanConfig import org.lfdecentralizedtrust.splice.sv.store.SvDsoStore import org.lfdecentralizedtrust.splice.util.{AssignedContract, TemplateJsonDecoder} import org.lfdecentralizedtrust.splice.util.PrettyInstances.* +import com.daml.metrics.api.MetricsContext import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer @@ -52,6 +54,7 @@ abstract class CalculateRewardsTriggerBase( private val svParty = store.key.svParty private val dsoParty = store.key.dsoParty + private val rewardMetrics = new RewardProcessingMetrics(context.metricsFactory) override def retrieveTasks()(implicit tc: TraceContext): Future[Seq[Task]] = for { calculateRewards <- store.listCalculateRewardsV2() @@ -99,8 +102,15 @@ abstract class CalculateRewardsTriggerBase( deduplicationOffset = offset, ) .yieldUnit() + delay = java.time.Duration.between( + task.calculateRewards.payload.roundClosedAt, + context.clock.now.toInstant, + ) + _ = rewardMetrics.calculateRewardsProcessingDelay.update(delay)( + MetricsContext.Empty.withExtraLabels("dryRun" -> isDryRun.toString) + ) } yield TaskSuccess( - s"created confirmation for CalculateRewardsV2 round $round" + s"created confirmation for CalculateRewardsV2 round $round, processingDelay=$delay" ) } } yield taskOutcome diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala index 36221f9524..c54cac2be8 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala @@ -29,8 +29,10 @@ import org.lfdecentralizedtrust.splice.http.v0.definitions.{ import org.lfdecentralizedtrust.splice.scan.admin.api.client.ScanConnection import org.lfdecentralizedtrust.splice.scan.config.ScanAppClientConfig import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority +import org.lfdecentralizedtrust.splice.sv.automation.RewardProcessingMetrics import org.lfdecentralizedtrust.splice.sv.config.SvScanConfig import org.lfdecentralizedtrust.splice.util.{AssignedContract, TemplateJsonDecoder} +import com.daml.metrics.api.MetricsContext import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer import org.lfdecentralizedtrust.splice.codegen.java.da.set.types.{Set as DamlSet} @@ -64,6 +66,7 @@ private[delegatebased] abstract class ProcessRewardsTriggerBase( with SvTaskBasedTrigger[ProcessRewardsV2Contract] { private val store = svTaskContext.dsoStore + private val rewardMetrics = new RewardProcessingMetrics(context.metricsFactory) override def completeTaskAsDsoDelegate( task: ProcessRewardsV2Contract, @@ -104,8 +107,13 @@ private[delegatebased] abstract class ProcessRewardsTriggerBase( ) .noDedup .yieldUnit() + delay = java.time.Duration + .between(task.payload.roundClosedAt, context.clock.now.toInstant) + _ = rewardMetrics.processRewardsProcessingDelay.update(delay)( + MetricsContext.Empty.withExtraLabels("dryRun" -> isDryRun.toString) + ) } yield TaskSuccess( - s"Processed batch for ProcessRewardsV2 round $round, batchHash=$batchHash, dryRun=$isDryRun" + s"Processed batch for ProcessRewardsV2 round $round, batchHash=$batchHash, dryRun=$isDryRun, processingDelay=$delay" ) } } From 3982dd0173ad6242bb970a140e8d2493a9f9b802 Mon Sep 17 00:00:00 2001 From: Divam Date: Fri, 8 May 2026 05:40:08 +0000 Subject: [PATCH 14/40] Test: modify integ test Signed-off-by: Divam --- ...BasedRewardsTimeBasedIntegrationTest.scala | 674 +++++++++++------- 1 file changed, 397 insertions(+), 277 deletions(-) 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 bbcfbaef33..d80d33fbc5 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 @@ -1,11 +1,10 @@ package org.lfdecentralizedtrust.splice.integration.tests -import com.daml.ledger.api.v2.event.Event -import com.daml.ledger.api.v2.transaction_filter import com.digitalasset.canton.HasExecutionContext -import com.digitalasset.canton.admin.api.client.commands.LedgerApiCommands.UpdateService.TransactionWrapper -import com.digitalasset.canton.config.RequireTypes.PositiveInt +import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.topology.PartyId +import com.digitalasset.canton.tracing.TraceContext +import java.time.Duration import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.{ allocationrequestv1, allocationv1, @@ -14,20 +13,20 @@ 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 ConfigTransforms.{ConfigurableApp, updateAutomationConfig} import org.lfdecentralizedtrust.splice.sv.config.InitialRewardConfig +import org.lfdecentralizedtrust.splice.validator.automation.ReceiveFaucetCouponTrigger +import org.lfdecentralizedtrust.splice.wallet.automation.CollectRewardsAndMergeAmuletsTrigger import org.lfdecentralizedtrust.splice.http.v0.definitions import definitions.DamlValueEncoding.members.CompactJson -import definitions.GetRewardAccountingActivityTotalsResponse -import definitions.GetRewardAccountingRootHashResponse +import definitions.GetRewardAccountingBatchResponse import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.IntegrationTestWithIsolatedEnvironment import org.lfdecentralizedtrust.splice.integration.tests.TokenStandardTest.CreateAllocationRequestResult -import org.lfdecentralizedtrust.splice.scan.automation.RewardComputationTrigger -import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredAmuletTransferInstructionTrigger +import org.lfdecentralizedtrust.splice.sv.automation.confirmation.{ + CalculateRewardsTrigger, + CalculateRewardsDryRunTrigger, +} import org.lfdecentralizedtrust.splice.util.{ ChoiceContextWithDisclosures, TimeTestUtil, @@ -36,8 +35,6 @@ import org.lfdecentralizedtrust.splice.util.{ } import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.SpliceTestConsoleEnvironment -import java.time.Duration -import java.util.UUID import scala.jdk.CollectionConverters.* import scala.util.Random @@ -46,7 +43,8 @@ import scala.util.Random // // DvP settlement from TokenStandardTest is used here just to confirm distribution of rewards @org.lfdecentralizedtrust.splice.util.scalatesttags.SpliceTokenTestTradingApp_1_0_0 -class TrafficBasedRewardsTimeBasedIntegrationTest +@org.lfdecentralizedtrust.splice.util.scalatesttags.SpliceAmulet_0_1_19 +abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase extends IntegrationTestWithIsolatedEnvironment with HasExecutionContext with WalletTestUtil @@ -55,12 +53,37 @@ class TrafficBasedRewardsTimeBasedIntegrationTest with ExternallySignedPartyTestUtil with TokenStandardTest { + // We run this test in multiple modes to confirm that various triggers work + // correctly based on the rewardConfig. Although this is somewhat redundant, + // it allows us to re-use the same test, and because it is expected that in long + // term only MintingTrafficBased would be necessary. + // + // The NoConfig is a special case; as it is simply testing that no regression + // happen until the rewardConfig is set to 'Some' value. Once we set the + // rewardConfig via vote, it will likely never get toggled back to None, and hence + // this mode could be removed altogether. + // + // Similarly OnlyRewardConfig and DryRun could be removed once traffic-based + // rewards is the default. + protected def rewardConfigMode: TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode + + private def dryRunEnabled: Boolean = + rewardConfigMode == TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.DryRun + + private def mintingTrafficBased: Boolean = + rewardConfigMode == TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.MintingTrafficBased + + private def producesV2Contracts: Boolean = dryRunEnabled || mintingTrafficBased + override def environmentDefinition: SpliceEnvironmentDefinition = EnvironmentDefinition - .simpleTopology1SvWithSimTime(this.getClass.getSimpleName) + .simpleTopology4SvsWithSimTime(this.getClass.getSimpleName) .withAdditionalSetup(implicit env => { Seq( sv1ValidatorBackend, + sv2ValidatorBackend, + sv3ValidatorBackend, + sv4ValidatorBackend, aliceValidatorBackend, bobValidatorBackend, splitwellValidatorBackend, @@ -68,22 +91,37 @@ class TrafficBasedRewardsTimeBasedIntegrationTest backend.participantClient.upload_dar_unless_exists(tokenStandardTestDarPath) } }) - .addConfigTransforms((_, config) => - updateAutomationConfig(ConfigurableApp.Scan)( - _.withPausedTrigger[RewardComputationTrigger] - )(config) + .addConfigTransform((_, config) => + rewardConfigMode match { + case TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.NoConfig => config + case TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.DryRun | + TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.OnlyRewardConfig | + TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.MintingTrafficBased => + ConfigTransforms.withRewardConfig( + InitialRewardConfig( + mintingVersion = + if (mintingTrafficBased) + TrafficBasedRewardsTimeBasedIntegrationTestBase.trafficBasedAppRewards + else TrafficBasedRewardsTimeBasedIntegrationTestBase.featuredAppMarkers, + dryRunVersion = Option.when(dryRunEnabled)( + TrafficBasedRewardsTimeBasedIntegrationTestBase.trafficBasedAppRewards + ), + appRewardCouponThreshold = + TrafficBasedRewardsTimeBasedIntegrationTestBase.appRewardCouponThreshold, + ) + )(config) + } ) + // Pause background wallet/validator automation so that we can test round with no activity, + // and even do calcs comparison for known transactions in a round .addConfigTransform((_, config) => - ConfigTransforms.withRewardConfig( - InitialRewardConfig( - mintingVersion = TrafficBasedRewardsTimeBasedIntegrationTest.trafficBasedAppRewards, - appRewardCouponThreshold = - TrafficBasedRewardsTimeBasedIntegrationTest.appRewardCouponThreshold, - ) + updateAutomationConfig(ConfigurableApp.Validator)( + _.withPausedTrigger[ReceiveFaucetCouponTrigger] + .withPausedTrigger[CollectRewardsAndMergeAmuletsTrigger] )(config) ) - "App activity records are created for featured app parties" in { implicit env => + "CIP-104 reward accounting pipeline works" in { implicit env => val aliceParty = onboardWalletUser(aliceWalletClient, aliceValidatorBackend) val bobParty = onboardWalletUser(bobWalletClient, bobValidatorBackend) val venuePartyHint = s"venue-party-${Random.nextInt()}" @@ -96,84 +134,147 @@ class TrafficBasedRewardsTimeBasedIntegrationTest ), ) - aliceWalletClient.tap(1000) - bobWalletClient.tap(1000) + aliceWalletClient.tap(20000) + bobWalletClient.tap(20000) assertOldestOpenRound(0) - clue("Reward accounting endpoints report 'Undetermined' before any data is available") { + clue("Reward accounting endpoints return 404 before any data is available") { sv1ScanBackend.getRewardAccountingEarliestAvailableRound() shouldBe None - sv1ScanBackend.getRewardAccountingActivityTotals(0L) shouldBe an[ - GetRewardAccountingActivityTotalsResponse.members.RewardAccountingActivityTotalsUndetermined - ] + sv1ScanBackend.getRewardAccountingActivityTotals(0L) shouldBe None } // Here we perform all settlements with verdict ingestion paused just to // confirm that activity record computations does happen properly even when // the ingestion is catching up, by reading the Tcs store data for the // archived rounds. I.e., pausing is not necessary, it merely improves test coverage. - // + // The pause of CalculateRewardsTrigger is necessary to confirm contracts + // were created for each round. + val calculateRewardsTriggers = + activeSvs.map(_.dsoAutomation.trigger[CalculateRewardsTrigger]) + val calculateRewardsDryRunTriggers = + activeSvs.map(_.dsoAutomation.trigger[CalculateRewardsDryRunTrigger]) + // Sequence of actions // Open rounds | Action // ------------+-------------------------------------- // 3, 4 | settle id0, grant venue FAP // 4, 5 | settle id1, grant alice FAP // 5, 6 | settle id2, cancel venue FAP - // 6, 7 | settle id3 - // 7, 8 | settle id4 - val ( - updateId0, - updateId1, - updateId2, - updateId3, - updateId4, - aliceCreateId, - svExpireId, - ) = + // 6, 7 | settle id3, (total 2 DvP trades) + // 7, 8 | settle id4, (total 3 DvP trades) + // 8, 9 | no-activity + // 9, 10 | settle id5, 1 DvP + 3 direct trades + // 10, 11 | settle id6, (total 5 DvP trades) + // 11, 12 | settle id7, (round not closed) + val (updateId0, updateId1, updateId3, updateId4, updateId5, updateId6, updateId7) = pauseScanVerdictIngestionWithin(sv1ScanBackend) { + setTriggersWithin(triggersToPauseAtStart = + calculateRewardsTriggers ++ calculateRewardsDryRunTriggers + ) { + + // 3 initial advances to get open rounds with staggered opensAt + for (round <- 1 to 3) { + advanceRoundsToNextRoundOpening + assertOldestOpenRound(round.toLong) + } + + val id0 = settleTrade(aliceParty, bobParty, venueParty) + grantFeaturedAppRight(splitwellWalletClient) - // 3 initial advances to get open rounds with staggered opensAt - for (round <- 1 to 3) { advanceRoundsToNextRoundOpening - assertOldestOpenRound(round.toLong) - } + assertOldestOpenRound(4) - val id0 = settleTrade(aliceParty, bobParty, venueParty) - grantFeaturedAppRight(splitwellWalletClient) + val id1 = settleTrade(aliceParty, bobParty, venueParty) + grantFeaturedAppRight(aliceWalletClient) - advanceRoundsToNextRoundOpening - assertOldestOpenRound(4) + advanceRoundsToNextRoundOpening + assertOldestOpenRound(5) - val id1 = settleTrade(aliceParty, bobParty, venueParty) - grantFeaturedAppRight(aliceWalletClient) + settleTrade(aliceParty, bobParty, venueParty) + settleTrade(aliceParty, bobParty, venueParty) - advanceRoundsToNextRoundOpening - assertOldestOpenRound(5) + advanceRoundsToNextRoundOpening + assertOldestOpenRound(6) - val id2 = settleTrade(aliceParty, bobParty, venueParty) - actAndCheck( - "Cancel venue's featured app right", - retryCommandSubmission(splitwellWalletClient.cancelFeaturedAppRight()), - )( - "Wait for right cancellation to be ingested", - _ => sv1ScanBackend.lookupFeaturedAppRight(venueParty) shouldBe None, - ) + val id3 = settleTrade(aliceParty, bobParty, venueParty) + settleTrade(aliceParty, bobParty, venueParty) - advanceRoundsToNextRoundOpening - assertOldestOpenRound(6) + advanceRoundsToNextRoundOpening + assertOldestOpenRound(7) - val id3 = settleTrade(aliceParty, bobParty, venueParty) + val id4 = settleTrade(aliceParty, bobParty, venueParty) + settleTrade(aliceParty, bobParty, venueParty) + settleTrade(aliceParty, bobParty, venueParty) - advanceRoundsToNextRoundOpening - assertOldestOpenRound(7) + advanceRoundsToNextRoundOpening + assertOldestOpenRound(8) - val id4 = settleTrade(aliceParty, bobParty, venueParty) + // No activity for round 8 - // alice creates an AmuletTransferInstruction which is archived by an SV - val (aliceCreateId, svExpireId) = - aliceCreateAndSvExpireInstruction(aliceParty, bobParty) + advanceRoundsToNextRoundOpening + assertOldestOpenRound(9) + + // Do only one DvP; this would not generate enough activity to reward the parties. + val id5 = settleTrade(aliceParty, bobParty, venueParty) + + // But do additional txs by alice such that only alice receives the rewards + (1 to 3).foreach { _ => + val offerCid = aliceWalletClient.createTransferOffer( + bobParty, + BigDecimal(10.0), + "round-9-alice-only", + CantonTimestamp.now().plus(Duration.ofMinutes(1)), + s"round9-transfer-${scala.util.Random.nextInt()}", + ) + bobWalletClient.acceptTransferOffer(offerCid) + } - (id0, id1, id2, id3, id4, aliceCreateId, svExpireId) + actAndCheck( + "Cancel venue's featured app right", + retryCommandSubmission(splitwellWalletClient.cancelFeaturedAppRight()), + )( + "Wait for right cancellation to be ingested", + _ => sv1ScanBackend.lookupFeaturedAppRight(venueParty) shouldBe None, + ) + + advanceRoundsToNextRoundOpening + assertOldestOpenRound(10) + + // Do five in a round to check nested BatchOfBatches processing + val id6 = settleTrade(aliceParty, bobParty, venueParty) + settleTrade(aliceParty, bobParty, venueParty) + settleTrade(aliceParty, bobParty, venueParty) + settleTrade(aliceParty, bobParty, venueParty) + settleTrade(aliceParty, bobParty, venueParty) + + advanceRoundsToNextRoundOpening + assertOldestOpenRound(11) + + val id7 = settleTrade(aliceParty, bobParty, venueParty) + settleTrade(aliceParty, bobParty, venueParty) + + if (producesV2Contracts) { + clue( + "CalculateRewardsV2 contracts should exist for each round" + ) { + eventually() { + val calculateRewardsRounds = + sv1Backend.appState.dsoStore + .listCalculateRewardsV2() + .futureValue + .map(_.payload.round.number) + .toSet + (0L to 10L).foreach { round => + calculateRewardsRounds should contain(round) withClue + s"CalculateRewardsV2 should exist for round $round" + } + } + } + } + + (id0, id1, id3, id4, id5, id6, id7) + } } def fetchEvent(updateId: String, label: String): definitions.EventHistoryItem = @@ -201,128 +302,185 @@ class TrafficBasedRewardsTimeBasedIntegrationTest assertNoAppActivity(event, "updateId1") } - // 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("updateId3") { + val event = fetchEvent(updateId3, "updateId3") + assertTrafficSummary(event, "updateId3") + assertAppActivity(event, "updateId3", Set(venueParty, aliceParty), expectedRound = 6) + } - // Capture per-round traffic costs for reward pipeline assertions. - // Each round has exactly one settlement in this test. - val trafficCostByRound: Map[Long, Long] = Map( - 5L -> clue("updateId2") { - val event = fetchEvent(updateId2, "updateId2") - assertTrafficSummary(event, "updateId2") - assertAppActivity(event, "updateId2", expectedProvidersByRound(5L), expectedRound = 5) - event.trafficSummary.value.totalTrafficCost - }, - 6L -> clue("updateId3") { - val event = fetchEvent(updateId3, "updateId3") - assertTrafficSummary(event, "updateId3") - assertAppActivity(event, "updateId3", expectedProvidersByRound(6L), expectedRound = 6) - event.trafficSummary.value.totalTrafficCost - }, - 7L -> clue("updateId4") { - val event = fetchEvent(updateId4, "updateId4") - assertTrafficSummary(event, "updateId4") - assertAppActivity(event, "updateId4", expectedProvidersByRound(7L), expectedRound = 7) - event.trafficSummary.value.totalTrafficCost - }, - ) + clue("updateId4") { + val event = fetchEvent(updateId4, "updateId4") + assertTrafficSummary(event, "updateId4") + assertAppActivity(event, "updateId4", Set(aliceParty, venueParty), expectedRound = 7) + } - clue("Alice-submitted create TransferInstruction has app activity for alice") { - val event = fetchEvent(aliceCreateId, "aliceCreateId") - event.verdict shouldBe defined - assertTrafficSummary(event, "aliceCreateId") - assertAppActivity(event, "aliceCreateId", Set(aliceParty), expectedRound = 7) + clue("updateId5") { + val event = fetchEvent(updateId5, "updateId5") + assertTrafficSummary(event, "updateId5") + // Round 9: one DvP — venue has activity but will be below the coupon threshold; + // alice's additional transfers push her above it. + assertAppActivity(event, "updateId5", Set(aliceParty, venueParty), expectedRound = 9) } - clue("SV-submitted expire TransferInstruction creates no app activity for alice") { - val event = fetchEvent(svExpireId, "svExpireId") - event.verdict shouldBe defined - assertTrafficSummary(event, "svExpireId") - assertNoAppActivity(event, "svExpireId") + clue("updateId6") { + val event = fetchEvent(updateId6, "updateId6") + assertTrafficSummary(event, "updateId6") + assertAppActivity(event, "updateId6", Set(aliceParty, venueParty), expectedRound = 10) } - // -- Reward pipeline endpoint checks -------------------------------------- - // ScanAggregationTrigger runs unpaused throughout the test and has already - // aggregated completed rounds. Run the paused RewardComputationTrigger to - // compute rewards, then verify the reward accounting HTTP endpoints. + clue("updateId7") { + val event = fetchEvent(updateId7, "updateId7") + assertTrafficSummary(event, "updateId7") + // Round 11: venue's FAP was cancelled in round 9, so only alice is a + // featured-app provider here. + assertAppActivity(event, "updateId7", Set(aliceParty), expectedRound = 11) + } - clue("Run the reward computation trigger") { - sv1ScanBackend.automation - .trigger[RewardComputationTrigger] - .runOnce() - .futureValue + if ( + rewardConfigMode != TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.NoConfig + ) { + assertRewardCalcs(aliceParty, venueParty) + } else { + clue("No CalculateRewardsV2 contracts are produced when rewardConfig is unset") { + sv1Backend.appState.dsoStore.listCalculateRewardsV2().futureValue shouldBe empty + } } - val earliest = clue("Verify earliest available round is returned") { - val e = sv1ScanBackend.getRewardAccountingEarliestAvailableRound() - e shouldBe defined - e.value + // Other misc API tests + clue("404 for non-existent batch data") { + sv1ScanBackend.getRewardAccountingBatch(6L, "0" * 64) shouldBe None } + } - val expectedProviders = expectedProvidersByRound.getOrElse( - earliest, - fail(s"No expected providers for earliest round $earliest"), - ) + private def assertRewardCalcs( + aliceParty: PartyId, + venueParty: PartyId, + )(implicit env: SpliceTestConsoleEnvironment): Unit = { + val allScanBackends = + Seq(sv1ScanBackend, sv2ScanBackend, sv3ScanBackend, sv4ScanBackend) - clue("Verify activity totals for the computed round") { - val totals = inside(sv1ScanBackend.getRewardAccountingActivityTotals(earliest)) { - case GetRewardAccountingActivityTotalsResponse.members - .RewardAccountingActivityTotalsOk(t) => - t + clue("Scan computes activity totals through round 10 on all SVs") { + eventually() { + allScanBackends.foreach( + _.getRewardAccountingActivityTotals(10L) shouldBe defined + ) } - totals.roundNumber shouldBe earliest - totals.activityRecordsCount should be > 0L - totals.activePartiesCount shouldBe expectedProviders.size.toLong - totals.totalAppActivityWeight should be > 0L - // The total weight must be at least as large as the traffic cost from the - // test's known settlement, since that settlement contributes activity records - // to the round (other background transactions may also contribute). - val roundTrafficCost = trafficCostByRound(earliest) - totals.totalAppActivityWeight should be >= roundTrafficCost } - clue("Verify root hash is available") { - val rootHash = inside(sv1ScanBackend.getRewardAccountingRootHash(earliest)) { - case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(h) => h + val totalsByRound: Map[Long, definitions.GetRewardAccountingActivityTotalsResponse] = + clue("Rounds 6..10 activity totals and root hash are computed") { + (6L to 10L).map { round => + val totals = sv1ScanBackend.getRewardAccountingActivityTotals(round) + totals shouldBe defined withClue s"Round $round should have totals" + totals.value.roundNumber shouldBe round + val rootHash = sv1ScanBackend.getRewardAccountingRootHash(round) + rootHash shouldBe defined withClue s"Round $round should have a root hash" + rootHash.value.roundNumber shouldBe round + rootHash.value.rootHash should have length 64 // hex-encoded SHA-256 + round -> totals.value + }.toMap } - rootHash.roundNumber shouldBe earliest - rootHash.rootHash should have length 64 // hex-encoded SHA-256 - } - clue("Verify batch contains expected providers with non-zero amounts") { - val rootHashHex = inside(sv1ScanBackend.getRewardAccountingRootHash(earliest)) { - case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(h) => - h.rootHash + // For rounds 0..5 scan cannot do totals calcs/produce a root hash, rounds + // 0..4 had no activity records; round 5 is excluded as the earliest round + // with records, and round 11 has not closed yet. + clue( + "Minting allowances: rounds 6, 7, 10 reward both parties; round 9 only alice" + ) { + def providersFor(round: Long): Set[String] = + getMintingAllowancesForRound(round).map(_.provider).toSet + + // Log per-round, per-party minting so test failures surface concrete + // values without needing to re-run with debug. + (6L to 10L).foreach { round => + val amounts = getMintingAllowancesForRound(round) + .map(a => s"${a.provider.split("::").head}=${a.amount}") + .mkString(", ") + logger.info(s"Round $round minting: $amounts")(TraceContext.empty) } - 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 + + // Rounds 6, 7, 10: both alice and venue did DvP trades. + Seq(6L, 7L, 10L).foreach { round => + providersFor(round) shouldBe Seq(aliceParty, venueParty) + .map(_.toProtoPrimitive) + .toSet withClue + s"Both parties should be rewarded in round $round" } + + // Round 8: no trades and background triggers are paused, so there is no + // activity records at all. + providersFor(8L) shouldBe Set() withClue + "Round 8 has no activity so no minting allowances are produced" + totalsByRound(8L).activityRecordsCount shouldBe 0L withClue + "Round 8 has no activity records" + totalsByRound(8L).totalAppActivityWeight shouldBe 0L withClue + "Round 8 has no activity weight" + + // Round 9: one DvP + alice→bob transfers; venue is below the coupon threshold + // so only alice receives minting allowances. + providersFor(9L) shouldBe Set(aliceParty.toProtoPrimitive) withClue + "Round 9: venue is below reward threshold so only alice should be rewarded" + } - clue("Verify response for non-existent data") { - sv1ScanBackend.getRewardAccountingActivityTotals(earliest + 100) shouldBe an[ - GetRewardAccountingActivityTotalsResponse.members.RewardAccountingActivityTotalsUndetermined - ] - sv1ScanBackend.getRewardAccountingRootHash(earliest + 100) shouldBe an[ - GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashUndetermined - ] - sv1ScanBackend.getRewardAccountingBatch(earliest, "0" * 64) shouldBe None + // The remaining assertions cover the handling of V2 contracts created on ledger. + if (producesV2Contracts) { + def listProcessRewardsV2Rounds(): Seq[Long] = + sv1Backend.appState.dsoStore + .listProcessRewardsV2() + .futureValue + .map(_.payload.round.number) + + clue("CalculateRewards and ProcessRewards triggers consume middle-round (6..10) contracts") { + // V2 contracts for rounds 6..10 should be processed by SVs + eventually() { + val remainingCalculate = sv1Backend.appState.dsoStore + .listCalculateRewardsV2() + .futureValue + .filter(c => c.payload.round.number >= 6L && c.payload.round.number <= 10L) + remainingCalculate shouldBe empty withClue + "Middle-round CalculateRewardsV2 contracts (6..10) should be consumed" + val remainingProcess = listProcessRewardsV2Rounds() + .filter(r => r >= 6L && r <= 10L) + remainingProcess shouldBe empty withClue + "Middle-round ProcessRewardsV2 contracts (6..10) should be consumed" + } + } + + // V2 contracts for rounds 0..5 and 11 remain unprocessed by triggers + // Which provides us opportunity to test the archive endpoint + if (dryRunEnabled) { + clue("archiveDryRunRewardAccountingContracts clears remaining rounds") { + // Archive them via the sv admin API and confirm both + // CalculateRewardsV2 and ProcessRewardsV2 are empty afterwards. + val otherRounds = sv1Backend.appState.dsoStore + .listCalculateRewardsV2() + .futureValue + .map(_.payload.round.number: Long) + .filterNot(r => r >= 6L && r <= 10L) + .distinct + otherRounds should not be empty withClue + "Expected CalculateRewardsV2 contracts for rounds outside [6..10] before archive" + actAndCheck( + "Archive dry-run reward accounting contracts for rounds outside [6..10]", + sv1Backend.archiveDryRunRewardAccountingContracts(otherRounds), + )( + "All dry-run CalculateRewardsV2 and ProcessRewardsV2 contracts should be archived", + _ => { + sv1Backend.appState.dsoStore + .listCalculateRewardsV2() + .futureValue shouldBe empty withClue + "All dry-run CalculateRewardsV2 contracts should be archived" + listProcessRewardsV2Rounds() shouldBe empty withClue + "All dry-run ProcessRewardsV2 contracts should be archived" + }, + ) + } + } + } else { + clue("No CalculateRewardsV2 contracts when V2 minting/dry-run is unset") { + sv1Backend.appState.dsoStore.listCalculateRewardsV2().futureValue shouldBe empty + } } } @@ -401,103 +559,20 @@ class TrafficBasedRewardsTimeBasedIntegrationTest } } - /** Alice creates an AmuletTransferInstruction with a short deadline, then we - * advance past the deadline and have the SV trigger archive it. Returns - * (aliceCreateId, svExpireId). - */ - private def aliceCreateAndSvExpireInstruction( - aliceParty: PartyId, - bobParty: PartyId, - )(implicit env: SpliceTestConsoleEnvironment): (String, String) = { - val participant = aliceValidatorBackend.participantClientWithAdminToken - val beginOffsetExclusive = participant.ledger_api.state.end() - - val trackingId = s"alice-instr-${UUID.randomUUID()}" - val expiry = Duration.ofMinutes(5) - val createResult = aliceWalletClient.createTokenStandardTransfer( - receiver = bobParty, - amount = BigDecimal(1.0), - description = "alice-to-bob transfer instruction", - expiresAt = getLedgerTime.plus(expiry), - trackingId = trackingId, - ) - val instructionCid = inside(createResult.output) { - case definitions.TransferInstructionResultOutput.members - .TransferInstructionPending(value) => - value.transferInstructionCid - } - - advanceTime(expiry.plusSeconds(60)) - - aliceWalletClient.tap(1) - - // ExpiredAmuletTransferInstructionTrigger is paused by default in test config - // so we explicitly resume it for the contract to be archived. - setTriggersWithin(triggersToResumeAtStart = - Seq(sv1Backend.dsoDelegateBasedAutomation.trigger[ExpiredAmuletTransferInstructionTrigger]) - ) { - eventually() { - aliceWalletClient.listTokenStandardTransfers() shouldBe empty - } - } - - findCreateAndArchiveUpdateIds(instructionCid, beginOffsetExclusive, aliceParty) - } - - /** Query alice's participant ledger for the create and expire `contractId` - * between the `beginOffsetExclusive` and the current ledger end. - */ - private def findCreateAndArchiveUpdateIds( - contractId: String, - beginOffsetExclusive: Long, - party: PartyId, - )(implicit env: SpliceTestConsoleEnvironment): (String, String) = { - val participant = aliceValidatorBackend.participantClientWithAdminToken - val ledgerEnd = participant.ledger_api.state.end() - - val txFormat = transaction_filter.TransactionFormat( - eventFormat = Some( - transaction_filter.EventFormat( - filtersByParty = Map(party.toLf -> transaction_filter.Filters(Nil)), - filtersForAnyParty = None, - verbose = false, - ) - ), - transactionShape = transaction_filter.TransactionShape.TRANSACTION_SHAPE_LEDGER_EFFECTS, - ) - - val updates = participant.ledger_api.updates.updates( - updateFormat = transaction_filter.UpdateFormat( - includeTransactions = Some(txFormat), - includeReassignments = None, - includeTopologyEvents = None, - ), - completeAfter = PositiveInt.MaxValue, - beginOffsetExclusive = beginOffsetExclusive, - endOffsetInclusive = Some(ledgerEnd), - ) - - val (createUid, archiveUid) = - updates.foldLeft((Option.empty[String], Option.empty[String])) { - case ((cu, au), TransactionWrapper(tx)) => - val hasCreate = tx.events.exists(_.event match { - case Event.Event.Created(c) => c.contractId == contractId - case _ => false - }) - val hasArchive = tx.events.exists(_.event match { - case Event.Event.Exercised(e) => e.contractId == contractId && e.consuming - case _ => false - }) - ( - if (cu.isEmpty && hasCreate) Some(tx.updateId) else cu, - if (au.isEmpty && hasArchive) Some(tx.updateId) else au, - ) - case (acc, _) => acc + private def getMintingAllowancesForRound( + round: Long + )(implicit + env: SpliceTestConsoleEnvironment + ): Seq[definitions.RewardAccountingMintingAllowance] = { + val hash = sv1ScanBackend.getRewardAccountingRootHash(round).value.rootHash + def walk(h: String): Seq[definitions.RewardAccountingMintingAllowance] = + sv1ScanBackend.getRewardAccountingBatch(round, h).toList.flatMap { + case GetRewardAccountingBatchResponse.members.RewardAccountingBatchOfBatches(b) => + b.childHashes.flatMap(walk) + case GetRewardAccountingBatchResponse.members.RewardAccountingBatchOfMintingAllowances(b) => + b.mintingAllowances.toSeq } - - withClue(s"create updateId for contract $contractId")(createUid shouldBe defined) - withClue(s"archive updateId for contract $contractId")(archiveUid shouldBe defined) - (createUid.value, archiveUid.value) + walk(hash) } private def settleTrade( @@ -593,12 +668,57 @@ class TrafficBasedRewardsTimeBasedIntegrationTest } } -object TrafficBasedRewardsTimeBasedIntegrationTest { +object TrafficBasedRewardsTimeBasedIntegrationTestBase { + + sealed trait RewardConfigMode + object RewardConfigMode { + // (AmuletConfig.rewardConfig = None). + case object NoConfig extends RewardConfigMode + // RewardConfig with dryRunVersion = None and mintingVersion = FeaturedAppMarkers + case object OnlyRewardConfig extends RewardConfigMode + // dryRunVersion = TrafficBased + case object DryRun extends RewardConfigMode + // mintingVersion = TrafficBased, dryRunVersion = None + case object MintingTrafficBased extends RewardConfigMode + } - // Use traffic-based app rewards (CIP-0104), not on-ledger coupon counting. val trafficBasedAppRewards = "RewardVersion_TrafficBasedAppRewards" + val featuredAppMarkers = "RewardVersion_FeaturedAppMarkers" + + // This threshold has been chosen to keep the venue's app activity below + // threshold for the round 9. See the test for details. + // + // appRewardCouponThreshold is in USD and compared against the minting + // allowance. For this test amuletPrice = 0.005, trafficPrice = 16.67 USD/MB, + // and issuancePerCoupon is observed to be ~100, so 30 KB of activity ≈ 50 USD + // (30/1000 MB × 16.67 × 100). + val appRewardCouponThreshold = BigDecimal("50") +} + +class TrafficBasedRewardsTimeBasedIntegrationTest + extends TrafficBasedRewardsTimeBasedIntegrationTestBase { + override protected val rewardConfigMode + : TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode = + TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.MintingTrafficBased +} + +class TrafficBasedRewardsDryRunTimeBasedIntegrationTest + extends TrafficBasedRewardsTimeBasedIntegrationTestBase { + override protected val rewardConfigMode + : TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode = + TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.DryRun +} + +class TrafficBasedRewardsOnlyRewardConfigTimeBasedIntegrationTest + extends TrafficBasedRewardsTimeBasedIntegrationTestBase { + override protected val rewardConfigMode + : TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode = + TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.OnlyRewardConfig +} - // 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) +class TrafficBasedRewardsNoRewardConfigTimeBasedIntegrationTest + extends TrafficBasedRewardsTimeBasedIntegrationTestBase { + override protected val rewardConfigMode + : TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode = + TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.NoConfig } From ba59335f4f3fd9ca8c514a8d35f93b2990f4e4ea Mon Sep 17 00:00:00 2001 From: Divam Date: Fri, 8 May 2026 05:39:52 +0000 Subject: [PATCH 15/40] Test: Add SvApp trigger test Signed-off-by: Divam --- ...RewardsSvAppTimeBasedIntegrationTest.scala | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala new file mode 100644 index 0000000000..f7ecd7bb51 --- /dev/null +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala @@ -0,0 +1,258 @@ +package org.lfdecentralizedtrust.splice.integration.tests + +import com.digitalasset.canton.HasExecutionContext +import com.digitalasset.canton.data.CantonTimestamp +import com.digitalasset.canton.topology.PartyId +import java.time.Duration +import java.util.Optional +import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletconfig.{ + AmuletConfig, + RewardConfig, + RewardVersion, + USD, +} +import org.lfdecentralizedtrust.splice.config.ConfigTransforms +import org.lfdecentralizedtrust.splice.http.v0.definitions +import definitions.GetRewardAccountingBatchResponse +import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition +import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.{ + IntegrationTestWithIsolatedEnvironment, + SpliceTestConsoleEnvironment, +} +import org.lfdecentralizedtrust.splice.sv.automation.confirmation.{ + CalculateRewardsDryRunTrigger, + CalculateRewardsTrigger, +} +import org.lfdecentralizedtrust.splice.sv.config.InitialRewardConfig +import org.lfdecentralizedtrust.splice.util.{ + AmuletConfigSchedule, + AmuletConfigUtil, + TimeTestUtil, + TriggerTestUtil, + WalletTestUtil, +} + +// This test focuses on the SV app side triggers testing +// - Turning on/off of dry-run and minting-version in rewardConfig +// And confirming that rewards processing works. +// +// Later this test would be extended to cover unhide, expire, etc +@org.lfdecentralizedtrust.splice.util.scalatesttags.SpliceAmulet_0_1_19 +class TrafficBasedRewardsSvAppTimeBasedIntegrationTest + extends IntegrationTestWithIsolatedEnvironment + with HasExecutionContext + with WalletTestUtil + with TriggerTestUtil + with TimeTestUtil + with AmuletConfigUtil { + + override def environmentDefinition: SpliceEnvironmentDefinition = + EnvironmentDefinition + .simpleTopology1SvWithSimTime(this.getClass.getSimpleName) + .addConfigTransform((_, config) => + ConfigTransforms.withRewardConfig( + InitialRewardConfig( + dryRunVersion = None, + appRewardCouponThreshold = BigDecimal("0"), + ) + )(config) + ) + + "Enable, disable of dryRunVersion/mintingVersion take effect at round closure" in { + implicit env => + val aliceParty = onboardWalletUser(aliceWalletClient, aliceValidatorBackend) + val bobParty = onboardWalletUser(bobWalletClient, bobValidatorBackend) + + aliceWalletClient.tap(20000) + + grantFeaturedAppRight(aliceWalletClient) + grantFeaturedAppRight(bobWalletClient) + + for (round <- 1 to 3) { + advanceRoundsToNextRoundOpening + assertOldestOpenRound(round.toLong) + } + + // oldest=3: rounds 3,4,5 open. + // Next round to open is R6, it will have dryRun enabled + clue("vote to enable dryRunVersion") { + changeRewardConfig(enableDryRun = true) + } + + advanceRoundsToNextRoundOpening + assertOldestOpenRound(4) + doTransfer(bobParty) + + // oldest=4: rounds 4,5,6 open. + // R7 will have the disabled config. + clue("vote to disable dryRunVersion") { + changeRewardConfig(enableDryRun = false) + } + + advanceRoundsToNextRoundOpening + assertOldestOpenRound(5) + doTransfer(bobParty) + + // oldest=5: rounds 5,6,7 open. R8 will have + // both dryRunVersion and mintingVersion set. + clue("vote to enable dryRunVersion + mintingVersion") { + changeRewardConfig(enableDryRun = true, enableMinting = true) + } + + val calculateRewardsDryRunTrigger = + sv1Backend.dsoAutomation.trigger[CalculateRewardsDryRunTrigger] + val calculateRewardsTrigger = + sv1Backend.dsoAutomation.trigger[CalculateRewardsTrigger] + + // Create activity for 6, 7, and 8 and confirm creation of CalculateRewardsV2 + setTriggersWithin( + triggersToPauseAtStart = Seq(calculateRewardsDryRunTrigger, calculateRewardsTrigger) + ) { + advanceRoundsToNextRoundOpening + assertOldestOpenRound(6) + doTransfer(bobParty) + + advanceRoundsToNextRoundOpening + assertOldestOpenRound(7) + doTransfer(bobParty) + + advanceRoundsToNextRoundOpening + assertOldestOpenRound(8) + doTransfer(bobParty) + + advanceRoundsToNextRoundOpening + assertOldestOpenRound(9) + doTransfer(bobParty) + + clue("CalculateRewardsV2 are created for rounds, 6 and 8") { + eventually() { + val v2s = sv1Backend.appState.dsoStore.listCalculateRewardsV2().futureValue + v2s.map(_.payload.round.number) should contain(6L) + v2s.map(_.payload.round.number) should not contain 7L + v2s + .filter(_.payload.round.number == 8L) + .map(_.payload.dryRun) + .toSet shouldBe Set(true, false) + } + } + } + + clue("Alice and Bob have minting allowances for R6") { + eventually() { + val hash = sv1ScanBackend.getRewardAccountingRootHash(6L).value.rootHash + val providers = walkBatch(6L, hash).map(_.provider) + providers should contain(aliceParty.toProtoPrimitive) + providers should contain(bobParty.toProtoPrimitive) + } + } + + clue("Alice and Bob have minting allowances for R8") { + eventually() { + val hash = sv1ScanBackend.getRewardAccountingRootHash(8L).value.rootHash + val providers = walkBatch(8L, hash).map(_.provider) + providers should contain(aliceParty.toProtoPrimitive) + providers should contain(bobParty.toProtoPrimitive) + } + } + + clue("Scan computes activity totals even for rounds with no dryRun/mintingVersion set") { + eventually() { + sv1ScanBackend.getRewardAccountingActivityTotals(5L) shouldBe defined + sv1ScanBackend.getRewardAccountingActivityTotals(7L) shouldBe defined + } + } + + clue("All CalculateRewardsV2 and ProcessRewardsV2 contracts consumed") { + eventually() { + sv1Backend.appState.dsoStore.listCalculateRewardsV2().futureValue shouldBe empty + sv1Backend.appState.dsoStore.listProcessRewardsV2().futureValue shouldBe empty + } + } + + clue("Alice and Bob received RewardCouponV2 for R8") { + eventually() { + val coupons = sv1Backend.appState.dsoStore.listRewardCouponsV2().futureValue + coupons.filter(c => + c.payload.round.number == 8L && c.payload.provider == aliceParty.toProtoPrimitive + ) should not be empty + coupons.filter(c => + c.payload.round.number == 8L && c.payload.provider == bobParty.toProtoPrimitive + ) should not be empty + } + } + } + + private def doTransfer( + bobParty: PartyId + )(implicit env: SpliceTestConsoleEnvironment): Unit = { + val offerCid = aliceWalletClient.createTransferOffer( + bobParty, + BigDecimal(10.0), + "activity", + CantonTimestamp.now().plus(Duration.ofMinutes(1)), + s"transfer-${scala.util.Random.nextInt()}", + ) + bobWalletClient.acceptTransferOffer(offerCid) + } + + private def walkBatch( + round: Long, + hash: String, + )(implicit + env: SpliceTestConsoleEnvironment + ): Seq[definitions.RewardAccountingMintingAllowance] = + sv1ScanBackend.getRewardAccountingBatch(round, hash).toList.flatMap { + case GetRewardAccountingBatchResponse.members.RewardAccountingBatchOfBatches(b) => + b.childHashes.flatMap(h => walkBatch(round, h)) + case GetRewardAccountingBatchResponse.members.RewardAccountingBatchOfMintingAllowances(b) => + b.mintingAllowances.toSeq + } + + private def assertOldestOpenRound( + expected: Long + )(implicit env: SpliceTestConsoleEnvironment): Unit = { + clue(s"Asserting oldest open round=$expected") { + eventually() { + val (openRounds, _) = sv1ScanBackend.getOpenAndIssuingMiningRounds() + val roundNumbers = openRounds.map(_.contract.payload.round.number.toLong).sorted + roundNumbers should have size 3 + roundNumbers.head shouldBe expected + } + } + } + + private def changeRewardConfig( + enableDryRun: Boolean, + enableMinting: Boolean = false, + )(implicit env: SpliceTestConsoleEnvironment): Unit = { + val amuletRules = sv1Backend.getDsoInfo().amuletRules + val existing = AmuletConfigSchedule(amuletRules).getConfigAsOf(env.environment.clock.now) + val rc = existing.rewardConfig.get() + val newRc = new RewardConfig( + if (enableMinting) RewardVersion.REWARDVERSION_TRAFFICBASEDAPPREWARDS + else rc.mintingVersion, + if (enableDryRun) Optional.of(RewardVersion.REWARDVERSION_TRAFFICBASEDAPPREWARDS) + else Optional.empty[RewardVersion](), + rc.batchSize, + rc.rewardCouponTimeToLive, + rc.appRewardCouponThreshold, + ) + val newConfig = new AmuletConfig[USD]( + existing.transferConfig, + existing.issuanceCurve, + existing.decentralizedSynchronizer, + existing.tickDuration, + existing.packageConfig, + existing.transferPreapprovalFee, + existing.featuredAppActivityMarkerAmount, + existing.optDevelopmentFundManager, + existing.externalPartyConfigStateTickDuration, + Optional.of(newRc), + ) + setAmuletConfig(Seq((None, newConfig, existing))) + eventually() { + sv1Backend.listVoteRequests() shouldBe empty + } + } + +} From 60730fc9a1d7044304ba4886154c713edabb1f42 Mon Sep 17 00:00:00 2001 From: Divam Date: Fri, 8 May 2026 05:43:14 +0000 Subject: [PATCH 16/40] updateTestConfigForParallelRuns Signed-off-by: Divam --- test-full-class-names-sim-time.log | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test-full-class-names-sim-time.log b/test-full-class-names-sim-time.log index a58e539232..a9c9e73b37 100644 --- a/test-full-class-names-sim-time.log +++ b/test-full-class-names-sim-time.log @@ -14,6 +14,10 @@ org.lfdecentralizedtrust.splice.integration.tests.TimeBasedTreasuryIntegrationTe org.lfdecentralizedtrust.splice.integration.tests.TimeBasedTreasuryIntegrationTestWithoutMerging org.lfdecentralizedtrust.splice.integration.tests.TokenStandardCliTestDataTimeBasedIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.TokenStandardMetadataTimeBasedIntegrationTest +org.lfdecentralizedtrust.splice.integration.tests.TrafficBasedRewardsDryRunTimeBasedIntegrationTest +org.lfdecentralizedtrust.splice.integration.tests.TrafficBasedRewardsNoRewardConfigTimeBasedIntegrationTest +org.lfdecentralizedtrust.splice.integration.tests.TrafficBasedRewardsOnlyRewardConfigTimeBasedIntegrationTest +org.lfdecentralizedtrust.splice.integration.tests.TrafficBasedRewardsSvAppTimeBasedIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.TrafficBasedRewardsTimeBasedIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.ValidatorFaucetCapZeroTimeBasedIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.ValidatorLicenseMetadataTimeBasedIntegrationTest From 99667ae5249c794943fcd20cb2f0f2cf3ab0faa4 Mon Sep 17 00:00:00 2001 From: Divam Date: Fri, 15 May 2026 08:40:49 +0000 Subject: [PATCH 17/40] Fix test assertions Signed-off-by: Divam --- ...BasedRewardsTimeBasedIntegrationTest.scala | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) 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 d80d33fbc5..0ba8ad118e 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 @@ -20,6 +20,8 @@ import org.lfdecentralizedtrust.splice.wallet.automation.CollectRewardsAndMergeA import org.lfdecentralizedtrust.splice.http.v0.definitions import definitions.DamlValueEncoding.members.CompactJson import definitions.GetRewardAccountingBatchResponse +import definitions.GetRewardAccountingActivityTotalsResponse +import definitions.GetRewardAccountingRootHashResponse import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.IntegrationTestWithIsolatedEnvironment import org.lfdecentralizedtrust.splice.integration.tests.TokenStandardTest.CreateAllocationRequestResult @@ -139,9 +141,11 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase assertOldestOpenRound(0) - clue("Reward accounting endpoints return 404 before any data is available") { + clue("Reward accounting endpoints report Undetermined before any data is available") { sv1ScanBackend.getRewardAccountingEarliestAvailableRound() shouldBe None - sv1ScanBackend.getRewardAccountingActivityTotals(0L) shouldBe None + sv1ScanBackend.getRewardAccountingActivityTotals(0L) shouldBe an[ + GetRewardAccountingActivityTotalsResponse.members.RewardAccountingActivityTotalsUndetermined + ] } // Here we perform all settlements with verdict ingestion paused just to @@ -361,23 +365,28 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase clue("Scan computes activity totals through round 10 on all SVs") { eventually() { - allScanBackends.foreach( - _.getRewardAccountingActivityTotals(10L) shouldBe defined - ) + allScanBackends.foreach { backend => + backend.getRewardAccountingActivityTotals(10L) shouldBe an[ + GetRewardAccountingActivityTotalsResponse.members.RewardAccountingActivityTotalsOk + ] + } } } - val totalsByRound: Map[Long, definitions.GetRewardAccountingActivityTotalsResponse] = + val totalsByRound: Map[Long, definitions.RewardAccountingActivityTotalsOk] = clue("Rounds 6..10 activity totals and root hash are computed") { (6L to 10L).map { round => - val totals = sv1ScanBackend.getRewardAccountingActivityTotals(round) - totals shouldBe defined withClue s"Round $round should have totals" - totals.value.roundNumber shouldBe round - val rootHash = sv1ScanBackend.getRewardAccountingRootHash(round) - rootHash shouldBe defined withClue s"Round $round should have a root hash" - rootHash.value.roundNumber shouldBe round - rootHash.value.rootHash should have length 64 // hex-encoded SHA-256 - round -> totals.value + val totalsOk = inside(sv1ScanBackend.getRewardAccountingActivityTotals(round)) { + case GetRewardAccountingActivityTotalsResponse.members + .RewardAccountingActivityTotalsOk(t) => + t + } withClue s"Round $round should have totals" + val rootHashOk = inside(sv1ScanBackend.getRewardAccountingRootHash(round)) { + case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(h) => + h + } withClue s"Round $round should have a root hash" + rootHashOk.rootHash should have length 64 // hex-encoded SHA-256 + round -> totalsOk }.toMap } @@ -564,7 +573,10 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase )(implicit env: SpliceTestConsoleEnvironment ): Seq[definitions.RewardAccountingMintingAllowance] = { - val hash = sv1ScanBackend.getRewardAccountingRootHash(round).value.rootHash + val hash = inside(sv1ScanBackend.getRewardAccountingRootHash(round)) { + case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(h) => + h.rootHash + } def walk(h: String): Seq[definitions.RewardAccountingMintingAllowance] = sv1ScanBackend.getRewardAccountingBatch(round, h).toList.flatMap { case GetRewardAccountingBatchResponse.members.RewardAccountingBatchOfBatches(b) => From 1a4d4a6575a7abadefcd2a19cdfb7667103b49ca Mon Sep 17 00:00:00 2001 From: Divam Date: Fri, 15 May 2026 08:41:20 +0000 Subject: [PATCH 18/40] Fix test Signed-off-by: Divam --- ...RewardsSvAppTimeBasedIntegrationTest.scala | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala index f7ecd7bb51..4e6b85e282 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala @@ -14,6 +14,8 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletconfig.{ import org.lfdecentralizedtrust.splice.config.ConfigTransforms import org.lfdecentralizedtrust.splice.http.v0.definitions import definitions.GetRewardAccountingBatchResponse +import definitions.GetRewardAccountingActivityTotalsResponse +import definitions.GetRewardAccountingRootHashResponse import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.{ IntegrationTestWithIsolatedEnvironment, @@ -139,7 +141,10 @@ class TrafficBasedRewardsSvAppTimeBasedIntegrationTest clue("Alice and Bob have minting allowances for R6") { eventually() { - val hash = sv1ScanBackend.getRewardAccountingRootHash(6L).value.rootHash + val hash = inside(sv1ScanBackend.getRewardAccountingRootHash(6L)) { + case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(h) => + h.rootHash + } val providers = walkBatch(6L, hash).map(_.provider) providers should contain(aliceParty.toProtoPrimitive) providers should contain(bobParty.toProtoPrimitive) @@ -148,7 +153,10 @@ class TrafficBasedRewardsSvAppTimeBasedIntegrationTest clue("Alice and Bob have minting allowances for R8") { eventually() { - val hash = sv1ScanBackend.getRewardAccountingRootHash(8L).value.rootHash + val hash = inside(sv1ScanBackend.getRewardAccountingRootHash(8L)) { + case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(h) => + h.rootHash + } val providers = walkBatch(8L, hash).map(_.provider) providers should contain(aliceParty.toProtoPrimitive) providers should contain(bobParty.toProtoPrimitive) @@ -157,8 +165,12 @@ class TrafficBasedRewardsSvAppTimeBasedIntegrationTest clue("Scan computes activity totals even for rounds with no dryRun/mintingVersion set") { eventually() { - sv1ScanBackend.getRewardAccountingActivityTotals(5L) shouldBe defined - sv1ScanBackend.getRewardAccountingActivityTotals(7L) shouldBe defined + sv1ScanBackend.getRewardAccountingActivityTotals(5L) shouldBe an[ + GetRewardAccountingActivityTotalsResponse.members.RewardAccountingActivityTotalsOk + ] + sv1ScanBackend.getRewardAccountingActivityTotals(7L) shouldBe an[ + GetRewardAccountingActivityTotalsResponse.members.RewardAccountingActivityTotalsOk + ] } } From cd6ece7c795de06b57a002be2c4d9d1602fe50cc Mon Sep 17 00:00:00 2001 From: Divam Date: Fri, 15 May 2026 17:43:21 +0900 Subject: [PATCH 19/40] Retry if getRootHash in undetermined, error on cannot-provide response Signed-off-by: Divam --- .../CalculateRewardsTrigger.scala | 114 ++++++++++-------- 1 file changed, 63 insertions(+), 51 deletions(-) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala index 650b606d33..a5efb09c93 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala @@ -12,6 +12,7 @@ import org.lfdecentralizedtrust.splice.automation.{ } import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.cryptohash.Hash import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.rewardaccountingv2.CalculateRewardsV2 +import org.lfdecentralizedtrust.splice.http.v0.definitions.GetRewardAccountingRootHashResponse import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.AmuletRules_StartProcessingRewardsV2 import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.ActionRequiringConfirmation import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.actionrequiringconfirmation.ARC_AmuletRules @@ -64,56 +65,64 @@ abstract class CalculateRewardsTriggerBase( task: Task )(implicit tc: TraceContext): Future[TaskOutcome] = { val round = task.calculateRewards.payload.round.number - for { - rootHash <- getRootHash(round) - action = startProcessingRewardsAction( - task.calculateRewards.contractId, - rootHash, - ) - queryResult <- store.lookupConfirmationByActionWithOffset(svParty, action) - taskOutcome <- queryResult match { - case QueryResult(_, Some(_)) => - Future.successful( - TaskSuccess( - s"skipping as confirmation from $svParty is already created for CalculateRewardsV2 round $round" - ) + getRootHash(round).flatMap { + case None => + Future.successful( + TaskSuccess( + s"waiting for scan to compute root hash for CalculateRewardsV2 round $round, will retry" ) - case QueryResult(offset, None) => - for { - dsoRules <- store.getDsoRules() - cmd = dsoRules.exercise( - _.exerciseDsoRules_ConfirmAction( - svParty.toProtoPrimitive, - action, - ) - ) - _ <- connection - .submit( - actAs = Seq(svParty), - readAs = Seq(dsoParty), - update = cmd, + ) + case Some(rootHash) => + val action = startProcessingRewardsAction( + task.calculateRewards.contractId, + rootHash, + ) + for { + queryResult <- store.lookupConfirmationByActionWithOffset(svParty, action) + taskOutcome <- queryResult match { + case QueryResult(_, Some(_)) => + Future.successful( + TaskSuccess( + s"skipping as confirmation from $svParty is already created for CalculateRewardsV2 round $round" + ) ) - .withDedup( - commandId = SpliceLedgerConnection.CommandId( - "org.lfdecentralizedtrust.splice.sv.createStartProcessingRewardsV2Confirmation", - Seq(svParty, dsoParty), - task.calculateRewards.contractId.contractId, - ), - deduplicationOffset = offset, + case QueryResult(offset, None) => + for { + dsoRules <- store.getDsoRules() + cmd = dsoRules.exercise( + _.exerciseDsoRules_ConfirmAction( + svParty.toProtoPrimitive, + action, + ) + ) + _ <- connection + .submit( + actAs = Seq(svParty), + readAs = Seq(dsoParty), + update = cmd, + ) + .withDedup( + commandId = SpliceLedgerConnection.CommandId( + "org.lfdecentralizedtrust.splice.sv.createStartProcessingRewardsV2Confirmation", + Seq(svParty, dsoParty), + task.calculateRewards.contractId.contractId, + ), + deduplicationOffset = offset, + ) + .yieldUnit() + delay = java.time.Duration.between( + task.calculateRewards.payload.roundClosedAt, + context.clock.now.toInstant, + ) + _ = rewardMetrics.calculateRewardsProcessingDelay.update(delay)( + MetricsContext.Empty.withExtraLabels("dryRun" -> isDryRun.toString) + ) + } yield TaskSuccess( + s"created confirmation for CalculateRewardsV2 round $round, processingDelay=$delay" ) - .yieldUnit() - delay = java.time.Duration.between( - task.calculateRewards.payload.roundClosedAt, - context.clock.now.toInstant, - ) - _ = rewardMetrics.calculateRewardsProcessingDelay.update(delay)( - MetricsContext.Empty.withExtraLabels("dryRun" -> isDryRun.toString) - ) - } yield TaskSuccess( - s"created confirmation for CalculateRewardsV2 round $round, processingDelay=$delay" - ) - } - } yield taskOutcome + } + } yield taskOutcome + } } override def isStaleTask(task: Task)(implicit @@ -144,13 +153,16 @@ abstract class CalculateRewardsTriggerBase( f(conn) } - private def getRootHash(round: Long)(implicit tc: TraceContext): Future[Hash] = + private def getRootHash(round: Long)(implicit tc: TraceContext): Future[Option[Hash]] = withScanConnection { conn => conn.getRewardAccountingRootHash(round).map { - case Some(response) => new Hash(response.rootHash) - case None => + case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(ok) => + Some(new Hash(ok.rootHash)) + case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashUndetermined(_) => + None + case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashCannotProvide(_) => throw new RuntimeException( - s"Root hash not available from scan for round $round" + s"Scan cannot provide root hash for round $round" ) } } From 01747f18ba1912342ada100df405b2c63f3c178c Mon Sep 17 00:00:00 2001 From: Divam Date: Mon, 18 May 2026 04:52:21 +0000 Subject: [PATCH 20/40] Do archive of round 0..5. use single SV topology Signed-off-by: Divam --- ...BasedRewardsTimeBasedIntegrationTest.scala | 93 ++++++++++--------- 1 file changed, 49 insertions(+), 44 deletions(-) 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 0ba8ad118e..d53c85cfef 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 @@ -79,13 +79,10 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase override def environmentDefinition: SpliceEnvironmentDefinition = EnvironmentDefinition - .simpleTopology4SvsWithSimTime(this.getClass.getSimpleName) + .simpleTopology1SvWithSimTime(this.getClass.getSimpleName) .withAdditionalSetup(implicit env => { Seq( sv1ValidatorBackend, - sv2ValidatorBackend, - sv3ValidatorBackend, - sv4ValidatorBackend, aliceValidatorBackend, bobValidatorBackend, splitwellValidatorBackend, @@ -203,6 +200,8 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase val id3 = settleTrade(aliceParty, bobParty, venueParty) settleTrade(aliceParty, bobParty, venueParty) + settleTrade(aliceParty, bobParty, venueParty) + settleTrade(aliceParty, bobParty, venueParty) advanceRoundsToNextRoundOpening assertOldestOpenRound(7) @@ -274,6 +273,48 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase s"CalculateRewardsV2 should exist for round $round" } } + // For rounds 0..5 scan will not be able calculate activity totals + // and root hashes, so archiving them here keep the + // CalculateRewardsTrigger's logs clean, as they expect each round + // with CalculateRewardsV2 to have a root hash. + if (dryRunEnabled) { + clue("Archive dry-run CalculateRewardsV2 for rounds 0..5 via sv admin API") { + sv1Backend.archiveDryRunRewardAccountingContracts((0L to 5L).toSeq) + } + } else { + // Bootstrapping a network with mintingVersion set to trafficBasedAppRewards + // is in principle not supported, as the round 0 will never have + // activity totals/root-hash calculated, and its CalculateRewardsV2 cannot be processed. + // So the only way to handle this in test is via direct archive. + clue("Archive CalculateRewardsV2 for rounds 0..5 directly as dso") { + val cids = sv1Backend.appState.dsoStore + .listCalculateRewardsV2() + .futureValue + .filter(c => c.payload.round.number >= 0L && c.payload.round.number <= 5L) + .map(_.contractId) + if (cids.nonEmpty) { + sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitJava( + userId = sv1Backend.config.ledgerApiUser, + actAs = Seq(dsoParty), + commands = cids.flatMap(_.exerciseArchive().commands.asScala.toSeq), + ) + } + } + } + clue("CalculateRewardsV2 contracts for rounds 0..5 are gone") { + eventually() { + val remaining = sv1Backend.appState.dsoStore + .listCalculateRewardsV2() + .futureValue + .map(_.payload.round.number) + .toSet + (0L to 5L).foreach { round => + remaining should not contain round withClue + s"CalculateRewardsV2 for round $round should be archived" + } + } + } } } @@ -360,16 +401,11 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase aliceParty: PartyId, venueParty: PartyId, )(implicit env: SpliceTestConsoleEnvironment): Unit = { - val allScanBackends = - Seq(sv1ScanBackend, sv2ScanBackend, sv3ScanBackend, sv4ScanBackend) - - clue("Scan computes activity totals through round 10 on all SVs") { + clue("Scan computes activity totals through round 10") { eventually() { - allScanBackends.foreach { backend => - backend.getRewardAccountingActivityTotals(10L) shouldBe an[ - GetRewardAccountingActivityTotalsResponse.members.RewardAccountingActivityTotalsOk - ] - } + sv1ScanBackend.getRewardAccountingActivityTotals(10L) shouldBe an[ + GetRewardAccountingActivityTotalsResponse.members.RewardAccountingActivityTotalsOk + ] } } @@ -455,37 +491,6 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase "Middle-round ProcessRewardsV2 contracts (6..10) should be consumed" } } - - // V2 contracts for rounds 0..5 and 11 remain unprocessed by triggers - // Which provides us opportunity to test the archive endpoint - if (dryRunEnabled) { - clue("archiveDryRunRewardAccountingContracts clears remaining rounds") { - // Archive them via the sv admin API and confirm both - // CalculateRewardsV2 and ProcessRewardsV2 are empty afterwards. - val otherRounds = sv1Backend.appState.dsoStore - .listCalculateRewardsV2() - .futureValue - .map(_.payload.round.number: Long) - .filterNot(r => r >= 6L && r <= 10L) - .distinct - otherRounds should not be empty withClue - "Expected CalculateRewardsV2 contracts for rounds outside [6..10] before archive" - actAndCheck( - "Archive dry-run reward accounting contracts for rounds outside [6..10]", - sv1Backend.archiveDryRunRewardAccountingContracts(otherRounds), - )( - "All dry-run CalculateRewardsV2 and ProcessRewardsV2 contracts should be archived", - _ => { - sv1Backend.appState.dsoStore - .listCalculateRewardsV2() - .futureValue shouldBe empty withClue - "All dry-run CalculateRewardsV2 contracts should be archived" - listProcessRewardsV2Rounds() shouldBe empty withClue - "All dry-run ProcessRewardsV2 contracts should be archived" - }, - ) - } - } } else { clue("No CalculateRewardsV2 contracts when V2 minting/dry-run is unset") { sv1Backend.appState.dsoStore.listCalculateRewardsV2().futureValue shouldBe empty From ebdfa4800118827d92be32ce71244a7565f3e2b7 Mon Sep 17 00:00:00 2001 From: Divam Date: Mon, 18 May 2026 15:07:43 +0900 Subject: [PATCH 21/40] Fix setAmuletConfig voting by other SVs Signed-off-by: Divam --- .../splice/util/AmuletConfigUtil.scala | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 9634c73da2..12491d554c 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 @@ -150,10 +150,11 @@ trait AmuletConfigUtil extends TestCommon { .requiredNumVotes(dsoRules) ) { eventually() { - sv.listVoteRequests() - .filter( - _.payload.trackingCid == voteRequestCid.contractId - ) should have size 1 + sv.listVoteRequests().filter { vr => + vr.contractId == voteRequestCid.contractId || + (vr.payload.trackingCid.isPresent && + vr.payload.trackingCid.get == voteRequestCid.contractId) + } should have size 1 } actAndCheck( s"${sv.name} casts a vote", { @@ -171,7 +172,6 @@ trait AmuletConfigUtil extends TestCommon { sv.lookupVoteRequest(voteRequestCid.contractId) .payload .votes should have size voteCount - sv.listVoteRequests() shouldBe empty }, ) } From 95fcdb84eb295471ed490a39e7d9042495bea2fe Mon Sep 17 00:00:00 2001 From: Divam Date: Mon, 18 May 2026 15:08:49 +0900 Subject: [PATCH 22/40] Use 4 SVs in SvApp test Signed-off-by: Divam --- ...cBasedRewardsSvAppTimeBasedIntegrationTest.scala | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala index 4e6b85e282..4917457629 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala @@ -50,7 +50,7 @@ class TrafficBasedRewardsSvAppTimeBasedIntegrationTest override def environmentDefinition: SpliceEnvironmentDefinition = EnvironmentDefinition - .simpleTopology1SvWithSimTime(this.getClass.getSimpleName) + .simpleTopology4SvsWithSimTime(this.getClass.getSimpleName) .addConfigTransform((_, config) => ConfigTransforms.withRewardConfig( InitialRewardConfig( @@ -101,14 +101,15 @@ class TrafficBasedRewardsSvAppTimeBasedIntegrationTest changeRewardConfig(enableDryRun = true, enableMinting = true) } - val calculateRewardsDryRunTrigger = - sv1Backend.dsoAutomation.trigger[CalculateRewardsDryRunTrigger] - val calculateRewardsTrigger = - sv1Backend.dsoAutomation.trigger[CalculateRewardsTrigger] + val svBackends = Seq(sv1Backend, sv2Backend, sv3Backend, sv4Backend) + val calculateRewardsDryRunTriggers = + svBackends.map(_.dsoAutomation.trigger[CalculateRewardsDryRunTrigger]) + val calculateRewardsTriggers = + svBackends.map(_.dsoAutomation.trigger[CalculateRewardsTrigger]) // Create activity for 6, 7, and 8 and confirm creation of CalculateRewardsV2 setTriggersWithin( - triggersToPauseAtStart = Seq(calculateRewardsDryRunTrigger, calculateRewardsTrigger) + triggersToPauseAtStart = calculateRewardsDryRunTriggers ++ calculateRewardsTriggers ) { advanceRoundsToNextRoundOpening assertOldestOpenRound(6) From 3ee73565c7cfe9ce57c3d212b2fa06ab464c9182 Mon Sep 17 00:00:00 2001 From: Divam Date: Wed, 20 May 2026 02:00:32 +0000 Subject: [PATCH 23/40] Test: Ignore SV submitted txs in activity record computation Mostly a patch reapply of earlier commit done on main Signed-off-by: Divam --- ...BasedRewardsTimeBasedIntegrationTest.scala | 137 +++++++++++++++++- 1 file changed, 135 insertions(+), 2 deletions(-) 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 d53c85cfef..5808b4dcb1 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 @@ -1,10 +1,15 @@ package org.lfdecentralizedtrust.splice.integration.tests +import com.daml.ledger.api.v2.event.Event +import com.daml.ledger.api.v2.transaction_filter import com.digitalasset.canton.HasExecutionContext +import com.digitalasset.canton.admin.api.client.commands.LedgerApiCommands.UpdateService.TransactionWrapper +import com.digitalasset.canton.config.RequireTypes.PositiveInt import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.topology.PartyId import com.digitalasset.canton.tracing.TraceContext import java.time.Duration +import java.util.UUID import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.{ allocationrequestv1, allocationv1, @@ -29,6 +34,7 @@ import org.lfdecentralizedtrust.splice.sv.automation.confirmation.{ CalculateRewardsTrigger, CalculateRewardsDryRunTrigger, } +import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredAmuletTransferInstructionTrigger import org.lfdecentralizedtrust.splice.util.{ ChoiceContextWithDisclosures, TimeTestUtil, @@ -168,7 +174,17 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase // 9, 10 | settle id5, 1 DvP + 3 direct trades // 10, 11 | settle id6, (total 5 DvP trades) // 11, 12 | settle id7, (round not closed) - val (updateId0, updateId1, updateId3, updateId4, updateId5, updateId6, updateId7) = + val ( + updateId0, + updateId1, + updateId3, + updateId4, + updateId5, + updateId6, + updateId7, + aliceCreateId, + svExpireId, + ) = pauseScanVerdictIngestionWithin(sv1ScanBackend) { setTriggersWithin(triggersToPauseAtStart = calculateRewardsTriggers ++ calculateRewardsDryRunTriggers @@ -210,6 +226,10 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase settleTrade(aliceParty, bobParty, venueParty) settleTrade(aliceParty, bobParty, venueParty) + // alice creates an AmuletTransferInstruction which is archived by an SV + val (aliceCreateId, svExpireId) = + aliceCreateAndSvExpireInstruction(aliceParty, bobParty) + advanceRoundsToNextRoundOpening assertOldestOpenRound(8) @@ -318,7 +338,7 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase } } - (id0, id1, id3, id4, id5, id6, id7) + (id0, id1, id3, id4, id5, id6, id7, aliceCreateId, svExpireId) } } @@ -359,6 +379,20 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase assertAppActivity(event, "updateId4", Set(aliceParty, venueParty), expectedRound = 7) } + clue("Alice-submitted create TransferInstruction has app activity for alice") { + val event = fetchEvent(aliceCreateId, "aliceCreateId") + event.verdict shouldBe defined + assertTrafficSummary(event, "aliceCreateId") + assertAppActivity(event, "aliceCreateId", Set(aliceParty), expectedRound = 7) + } + + clue("SV-submitted expire TransferInstruction creates no app activity for alice") { + val event = fetchEvent(svExpireId, "svExpireId") + event.verdict shouldBe defined + assertTrafficSummary(event, "svExpireId") + assertNoAppActivity(event, "svExpireId") + } + clue("updateId5") { val event = fetchEvent(updateId5, "updateId5") assertTrafficSummary(event, "updateId5") @@ -527,6 +561,105 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase } } + /** Alice creates an AmuletTransferInstruction with a short deadline, then we + * advance past the deadline and have the SV trigger archive it. Returns + * (aliceCreateId, svExpireId). + */ + private def aliceCreateAndSvExpireInstruction( + aliceParty: PartyId, + bobParty: PartyId, + )(implicit env: SpliceTestConsoleEnvironment): (String, String) = { + val participant = aliceValidatorBackend.participantClientWithAdminToken + val beginOffsetExclusive = participant.ledger_api.state.end() + + val trackingId = s"alice-instr-${UUID.randomUUID()}" + val expiry = Duration.ofMinutes(5) + val createResult = aliceWalletClient.createTokenStandardTransfer( + receiver = bobParty, + amount = BigDecimal(1.0), + description = "alice-to-bob transfer instruction", + expiresAt = getLedgerTime.plus(expiry), + trackingId = trackingId, + ) + val instructionCid = inside(createResult.output) { + case definitions.TransferInstructionResultOutput.members + .TransferInstructionPending(value) => + value.transferInstructionCid + } + + advanceTime(expiry.plusSeconds(60)) + + aliceWalletClient.tap(1) + + // ExpiredAmuletTransferInstructionTrigger is paused by default in test config + // so we explicitly resume it for the contract to be archived. + setTriggersWithin(triggersToResumeAtStart = + Seq(sv1Backend.dsoDelegateBasedAutomation.trigger[ExpiredAmuletTransferInstructionTrigger]) + ) { + eventually() { + aliceWalletClient.listTokenStandardTransfers() shouldBe empty + } + } + + findCreateAndArchiveUpdateIds(instructionCid, beginOffsetExclusive, aliceParty) + } + + /** Query alice's participant ledger for the create and expire `contractId` + * between the `beginOffsetExclusive` and the current ledger end. + */ + private def findCreateAndArchiveUpdateIds( + contractId: String, + beginOffsetExclusive: Long, + party: PartyId, + )(implicit env: SpliceTestConsoleEnvironment): (String, String) = { + val participant = aliceValidatorBackend.participantClientWithAdminToken + val ledgerEnd = participant.ledger_api.state.end() + + val txFormat = transaction_filter.TransactionFormat( + eventFormat = Some( + transaction_filter.EventFormat( + filtersByParty = Map(party.toLf -> transaction_filter.Filters(Nil)), + filtersForAnyParty = None, + verbose = false, + ) + ), + transactionShape = transaction_filter.TransactionShape.TRANSACTION_SHAPE_LEDGER_EFFECTS, + ) + + val updates = participant.ledger_api.updates.updates( + updateFormat = transaction_filter.UpdateFormat( + includeTransactions = Some(txFormat), + includeReassignments = None, + includeTopologyEvents = None, + ), + completeAfter = PositiveInt.MaxValue, + beginOffsetExclusive = beginOffsetExclusive, + endOffsetInclusive = Some(ledgerEnd), + ) + + val (createUid, archiveUid) = + updates.foldLeft((Option.empty[String], Option.empty[String])) { + case ((cu, au), TransactionWrapper(tx)) => + val hasCreate = tx.events.exists(_.event match { + case Event.Event.Created(c) => c.contractId == contractId + case _ => false + }) + val hasArchive = tx.events.exists(_.event match { + case Event.Event.Exercised(e) => e.contractId == contractId && e.consuming + case _ => false + }) + ( + if (cu.isEmpty && hasCreate) Some(tx.updateId) else cu, + if (au.isEmpty && hasArchive) Some(tx.updateId) else au, + ) + case (acc, _) => acc + } + + withClue(s"create updateId for contract $contractId")(createUid shouldBe defined) + withClue(s"archive updateId for contract $contractId")(archiveUid shouldBe defined) + (createUid.value, archiveUid.value) + } + private def assertAppActivity( event: definitions.EventHistoryItem, cluePrefix: String, From d883a33c48839422af540269711616840c3c9293 Mon Sep 17 00:00:00 2001 From: Divam Date: Wed, 20 May 2026 02:26:58 +0000 Subject: [PATCH 24/40] Test: Keep only DryRun and MintingTrafficBased flows Signed-off-by: Divam --- ...BasedRewardsTimeBasedIntegrationTest.scala | 220 +++++++----------- test-full-class-names-sim-time.log | 2 - 2 files changed, 83 insertions(+), 139 deletions(-) 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 5808b4dcb1..74712f128f 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 @@ -61,18 +61,6 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase with ExternallySignedPartyTestUtil with TokenStandardTest { - // We run this test in multiple modes to confirm that various triggers work - // correctly based on the rewardConfig. Although this is somewhat redundant, - // it allows us to re-use the same test, and because it is expected that in long - // term only MintingTrafficBased would be necessary. - // - // The NoConfig is a special case; as it is simply testing that no regression - // happen until the rewardConfig is set to 'Some' value. Once we set the - // rewardConfig via vote, it will likely never get toggled back to None, and hence - // this mode could be removed altogether. - // - // Similarly OnlyRewardConfig and DryRun could be removed once traffic-based - // rewards is the default. protected def rewardConfigMode: TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode private def dryRunEnabled: Boolean = @@ -81,8 +69,6 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase private def mintingTrafficBased: Boolean = rewardConfigMode == TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.MintingTrafficBased - private def producesV2Contracts: Boolean = dryRunEnabled || mintingTrafficBased - override def environmentDefinition: SpliceEnvironmentDefinition = EnvironmentDefinition .simpleTopology1SvWithSimTime(this.getClass.getSimpleName) @@ -97,25 +83,19 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase } }) .addConfigTransform((_, config) => - rewardConfigMode match { - case TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.NoConfig => config - case TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.DryRun | - TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.OnlyRewardConfig | - TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.MintingTrafficBased => - ConfigTransforms.withRewardConfig( - InitialRewardConfig( - mintingVersion = - if (mintingTrafficBased) - TrafficBasedRewardsTimeBasedIntegrationTestBase.trafficBasedAppRewards - else TrafficBasedRewardsTimeBasedIntegrationTestBase.featuredAppMarkers, - dryRunVersion = Option.when(dryRunEnabled)( - TrafficBasedRewardsTimeBasedIntegrationTestBase.trafficBasedAppRewards - ), - appRewardCouponThreshold = - TrafficBasedRewardsTimeBasedIntegrationTestBase.appRewardCouponThreshold, - ) - )(config) - } + ConfigTransforms.withRewardConfig( + InitialRewardConfig( + mintingVersion = + if (mintingTrafficBased) + TrafficBasedRewardsTimeBasedIntegrationTestBase.trafficBasedAppRewards + else TrafficBasedRewardsTimeBasedIntegrationTestBase.featuredAppMarkers, + dryRunVersion = Option.when(dryRunEnabled)( + TrafficBasedRewardsTimeBasedIntegrationTestBase.trafficBasedAppRewards + ), + appRewardCouponThreshold = + TrafficBasedRewardsTimeBasedIntegrationTestBase.appRewardCouponThreshold, + ) + )(config) ) // Pause background wallet/validator automation so that we can test round with no activity, // and even do calcs comparison for known transactions in a round @@ -277,62 +257,60 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase val id7 = settleTrade(aliceParty, bobParty, venueParty) settleTrade(aliceParty, bobParty, venueParty) - if (producesV2Contracts) { - clue( - "CalculateRewardsV2 contracts should exist for each round" - ) { - eventually() { - val calculateRewardsRounds = - sv1Backend.appState.dsoStore - .listCalculateRewardsV2() - .futureValue - .map(_.payload.round.number) - .toSet - (0L to 10L).foreach { round => - calculateRewardsRounds should contain(round) withClue - s"CalculateRewardsV2 should exist for round $round" - } + clue( + "CalculateRewardsV2 contracts should exist for each round" + ) { + eventually() { + val calculateRewardsRounds = + sv1Backend.appState.dsoStore + .listCalculateRewardsV2() + .futureValue + .map(_.payload.round.number) + .toSet + (0L to 10L).foreach { round => + calculateRewardsRounds should contain(round) withClue + s"CalculateRewardsV2 should exist for round $round" } - // For rounds 0..5 scan will not be able calculate activity totals - // and root hashes, so archiving them here keep the - // CalculateRewardsTrigger's logs clean, as they expect each round - // with CalculateRewardsV2 to have a root hash. - if (dryRunEnabled) { - clue("Archive dry-run CalculateRewardsV2 for rounds 0..5 via sv admin API") { - sv1Backend.archiveDryRunRewardAccountingContracts((0L to 5L).toSeq) - } - } else { - // Bootstrapping a network with mintingVersion set to trafficBasedAppRewards - // is in principle not supported, as the round 0 will never have - // activity totals/root-hash calculated, and its CalculateRewardsV2 cannot be processed. - // So the only way to handle this in test is via direct archive. - clue("Archive CalculateRewardsV2 for rounds 0..5 directly as dso") { - val cids = sv1Backend.appState.dsoStore - .listCalculateRewardsV2() - .futureValue - .filter(c => c.payload.round.number >= 0L && c.payload.round.number <= 5L) - .map(_.contractId) - if (cids.nonEmpty) { - sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands - .submitJava( - userId = sv1Backend.config.ledgerApiUser, - actAs = Seq(dsoParty), - commands = cids.flatMap(_.exerciseArchive().commands.asScala.toSeq), - ) - } + } + // For rounds 0..5 scan will not be able calculate activity totals + // and root hashes, so archiving them here keep the + // CalculateRewardsTrigger's logs clean, as they expect each round + // with CalculateRewardsV2 to have a root hash. + if (dryRunEnabled) { + clue("Archive dry-run CalculateRewardsV2 for rounds 0..5 via sv admin API") { + sv1Backend.archiveDryRunRewardAccountingContracts((0L to 5L).toSeq) + } + } else { + // Bootstrapping a network with mintingVersion set to trafficBasedAppRewards + // is in principle not supported, as the round 0 will never have + // activity totals/root-hash calculated, and its CalculateRewardsV2 cannot be processed. + // So the only way to handle this in test is via direct archive. + clue("Archive CalculateRewardsV2 for rounds 0..5 directly as dso") { + val cids = sv1Backend.appState.dsoStore + .listCalculateRewardsV2() + .futureValue + .filter(c => c.payload.round.number >= 0L && c.payload.round.number <= 5L) + .map(_.contractId) + if (cids.nonEmpty) { + sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitJava( + userId = sv1Backend.config.ledgerApiUser, + actAs = Seq(dsoParty), + commands = cids.flatMap(_.exerciseArchive().commands.asScala.toSeq), + ) } } - clue("CalculateRewardsV2 contracts for rounds 0..5 are gone") { - eventually() { - val remaining = sv1Backend.appState.dsoStore - .listCalculateRewardsV2() - .futureValue - .map(_.payload.round.number) - .toSet - (0L to 5L).foreach { round => - remaining should not contain round withClue - s"CalculateRewardsV2 for round $round should be archived" - } + } + clue("CalculateRewardsV2 contracts for rounds 0..5 are gone") { + eventually() { + val remaining = sv1Backend.appState.dsoStore + .listCalculateRewardsV2() + .futureValue + .map(_.payload.round.number) + .toSet + (0L to 5L).foreach { round => + remaining should not contain round withClue + s"CalculateRewardsV2 for round $round should be archived" } } } @@ -415,15 +393,7 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase assertAppActivity(event, "updateId7", Set(aliceParty), expectedRound = 11) } - if ( - rewardConfigMode != TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.NoConfig - ) { - assertRewardCalcs(aliceParty, venueParty) - } else { - clue("No CalculateRewardsV2 contracts are produced when rewardConfig is unset") { - sv1Backend.appState.dsoStore.listCalculateRewardsV2().futureValue shouldBe empty - } - } + assertRewardCalcs(aliceParty, venueParty) // Other misc API tests clue("404 for non-existent batch data") { @@ -503,31 +473,25 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase } // The remaining assertions cover the handling of V2 contracts created on ledger. - if (producesV2Contracts) { - def listProcessRewardsV2Rounds(): Seq[Long] = - sv1Backend.appState.dsoStore - .listProcessRewardsV2() + def listProcessRewardsV2Rounds(): Seq[Long] = + sv1Backend.appState.dsoStore + .listProcessRewardsV2() + .futureValue + .map(_.payload.round.number) + + clue("CalculateRewards and ProcessRewards triggers consume middle-round (6..10) contracts") { + // V2 contracts for rounds 6..10 should be processed by SVs + eventually() { + val remainingCalculate = sv1Backend.appState.dsoStore + .listCalculateRewardsV2() .futureValue - .map(_.payload.round.number) - - clue("CalculateRewards and ProcessRewards triggers consume middle-round (6..10) contracts") { - // V2 contracts for rounds 6..10 should be processed by SVs - eventually() { - val remainingCalculate = sv1Backend.appState.dsoStore - .listCalculateRewardsV2() - .futureValue - .filter(c => c.payload.round.number >= 6L && c.payload.round.number <= 10L) - remainingCalculate shouldBe empty withClue - "Middle-round CalculateRewardsV2 contracts (6..10) should be consumed" - val remainingProcess = listProcessRewardsV2Rounds() - .filter(r => r >= 6L && r <= 10L) - remainingProcess shouldBe empty withClue - "Middle-round ProcessRewardsV2 contracts (6..10) should be consumed" - } - } - } else { - clue("No CalculateRewardsV2 contracts when V2 minting/dry-run is unset") { - sv1Backend.appState.dsoStore.listCalculateRewardsV2().futureValue shouldBe empty + .filter(c => c.payload.round.number >= 6L && c.payload.round.number <= 10L) + remainingCalculate shouldBe empty withClue + "Middle-round CalculateRewardsV2 contracts (6..10) should be consumed" + val remainingProcess = listProcessRewardsV2Rounds() + .filter(r => r >= 6L && r <= 10L) + remainingProcess shouldBe empty withClue + "Middle-round ProcessRewardsV2 contracts (6..10) should be consumed" } } } @@ -822,10 +786,6 @@ object TrafficBasedRewardsTimeBasedIntegrationTestBase { sealed trait RewardConfigMode object RewardConfigMode { - // (AmuletConfig.rewardConfig = None). - case object NoConfig extends RewardConfigMode - // RewardConfig with dryRunVersion = None and mintingVersion = FeaturedAppMarkers - case object OnlyRewardConfig extends RewardConfigMode // dryRunVersion = TrafficBased case object DryRun extends RewardConfigMode // mintingVersion = TrafficBased, dryRunVersion = None @@ -858,17 +818,3 @@ class TrafficBasedRewardsDryRunTimeBasedIntegrationTest : TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode = TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.DryRun } - -class TrafficBasedRewardsOnlyRewardConfigTimeBasedIntegrationTest - extends TrafficBasedRewardsTimeBasedIntegrationTestBase { - override protected val rewardConfigMode - : TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode = - TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.OnlyRewardConfig -} - -class TrafficBasedRewardsNoRewardConfigTimeBasedIntegrationTest - extends TrafficBasedRewardsTimeBasedIntegrationTestBase { - override protected val rewardConfigMode - : TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode = - TrafficBasedRewardsTimeBasedIntegrationTestBase.RewardConfigMode.NoConfig -} diff --git a/test-full-class-names-sim-time.log b/test-full-class-names-sim-time.log index a9c9e73b37..e05e384aec 100644 --- a/test-full-class-names-sim-time.log +++ b/test-full-class-names-sim-time.log @@ -15,8 +15,6 @@ org.lfdecentralizedtrust.splice.integration.tests.TimeBasedTreasuryIntegrationTe org.lfdecentralizedtrust.splice.integration.tests.TokenStandardCliTestDataTimeBasedIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.TokenStandardMetadataTimeBasedIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.TrafficBasedRewardsDryRunTimeBasedIntegrationTest -org.lfdecentralizedtrust.splice.integration.tests.TrafficBasedRewardsNoRewardConfigTimeBasedIntegrationTest -org.lfdecentralizedtrust.splice.integration.tests.TrafficBasedRewardsOnlyRewardConfigTimeBasedIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.TrafficBasedRewardsSvAppTimeBasedIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.TrafficBasedRewardsTimeBasedIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.ValidatorFaucetCapZeroTimeBasedIntegrationTest From 487d0ba09f98879431a09045ab1930ae850867ae Mon Sep 17 00:00:00 2001 From: Divam Date: Wed, 20 May 2026 02:39:41 +0000 Subject: [PATCH 25/40] Filter ProcessRewardsV2 contracts by dryRun at the source Signed-off-by: Divam --- .../delegatebased/ProcessRewardsTrigger.scala | 79 +++++++++---------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala index c54cac2be8..e637ae15c2 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala @@ -3,7 +3,9 @@ package org.lfdecentralizedtrust.splice.sv.automation.delegatebased +import org.apache.pekko.NotUsed import org.apache.pekko.stream.Materializer +import org.apache.pekko.stream.scaladsl.Source import org.lfdecentralizedtrust.splice.automation.{ OnAssignedContractTrigger, TaskOutcome, @@ -68,54 +70,51 @@ private[delegatebased] abstract class ProcessRewardsTriggerBase( private val store = svTaskContext.dsoStore private val rewardMetrics = new RewardProcessingMetrics(context.metricsFactory) + override protected def source(implicit + traceContext: TraceContext + ): Source[ProcessRewardsV2Contract, NotUsed] = + super.source.filter(_.payload.dryRun == isDryRun) + override def completeTaskAsDsoDelegate( task: ProcessRewardsV2Contract, controller: String, )(implicit tc: TraceContext): Future[TaskOutcome] = { - if (task.payload.dryRun != isDryRun) { - Future.successful( - TaskSuccess( - s"Skipping ProcessRewardsV2 for round ${task.payload.round.number} with dryRun=${task.payload.dryRun}" - ) + val round = task.payload.round.number + val batchHash = task.payload.batchHash.value + val batchF = fetchBatch(round, batchHash) + val dsoRulesF = store.getDsoRules() + for { + batch <- batchF + dsoRules <- dsoRulesF + damlBatch = convertBatch(batch) + choiceArg = new ProcessRewardsV2_ProcessBatch( + damlBatch, + new DamlSet(java.util.Collections.emptyMap()), ) - } else { - val round = task.payload.round.number - val batchHash = task.payload.batchHash.value - val batchF = fetchBatch(round, batchHash) - val dsoRulesF = store.getDsoRules() - for { - batch <- batchF - dsoRules <- dsoRulesF - damlBatch = convertBatch(batch) - choiceArg = new ProcessRewardsV2_ProcessBatch( - damlBatch, - new DamlSet(java.util.Collections.emptyMap()), - ) - cmd = dsoRules.exercise( - _.exerciseDsoRules_ProcessRewardsV2_ProcessBatch( - task.contractId, - choiceArg, - controller, - ) + cmd = dsoRules.exercise( + _.exerciseDsoRules_ProcessRewardsV2_ProcessBatch( + task.contractId, + choiceArg, + controller, ) - _ <- svTaskContext - .connection(SpliceLedgerConnectionPriority.Low) - .submit( - Seq(store.key.svParty), - Seq(store.key.dsoParty), - cmd, - ) - .noDedup - .yieldUnit() - delay = java.time.Duration - .between(task.payload.roundClosedAt, context.clock.now.toInstant) - _ = rewardMetrics.processRewardsProcessingDelay.update(delay)( - MetricsContext.Empty.withExtraLabels("dryRun" -> isDryRun.toString) + ) + _ <- svTaskContext + .connection(SpliceLedgerConnectionPriority.Low) + .submit( + Seq(store.key.svParty), + Seq(store.key.dsoParty), + cmd, ) - } yield TaskSuccess( - s"Processed batch for ProcessRewardsV2 round $round, batchHash=$batchHash, dryRun=$isDryRun, processingDelay=$delay" + .noDedup + .yieldUnit() + delay = java.time.Duration + .between(task.payload.roundClosedAt, context.clock.now.toInstant) + _ = rewardMetrics.processRewardsProcessingDelay.update(delay)( + MetricsContext.Empty.withExtraLabels("dryRun" -> isDryRun.toString) ) - } + } yield TaskSuccess( + s"Processed batch for ProcessRewardsV2 round $round, batchHash=$batchHash, dryRun=$isDryRun, processingDelay=$delay" + ) } private def convertBatch( From 372125510a5660cfb040b584ce70f2e55cce57ba Mon Sep 17 00:00:00 2001 From: Divam Date: Wed, 20 May 2026 12:02:51 +0900 Subject: [PATCH 26/40] Just retry on scan connection failure Signed-off-by: Divam --- .../confirmation/CalculateRewardsTrigger.scala | 10 +--------- .../delegatebased/ProcessRewardsTrigger.scala | 10 +--------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala index a5efb09c93..cffd286c24 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala @@ -34,7 +34,6 @@ import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer import scala.concurrent.{ExecutionContextExecutor, Future} -import scala.util.{Failure, Success} abstract class CalculateRewardsTriggerBase( override protected val context: TriggerContext, @@ -144,14 +143,7 @@ abstract class CalculateRewardsTriggerBase( loggerFactory, retryConnectionOnInitialFailure = false, ) - .transformWith { - case Failure(ex) => - Future.failed( - new RuntimeException("Failed to connect to scan for root hash lookup", ex) - ) - case Success(conn) => - f(conn) - } + .flatMap(f) private def getRootHash(round: Long)(implicit tc: TraceContext): Future[Option[Hash]] = withScanConnection { conn => diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala index e637ae15c2..f7f6618ae7 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala @@ -42,7 +42,6 @@ import org.lfdecentralizedtrust.splice.codegen.java.da.set.types.{Set as DamlSet import java.math.BigDecimal import scala.concurrent.{ExecutionContextExecutor, Future} import scala.jdk.CollectionConverters.* -import scala.util.{Failure, Success} import ProcessRewardsTriggerBase.* @@ -162,14 +161,7 @@ private[delegatebased] abstract class ProcessRewardsTriggerBase( loggerFactory, retryConnectionOnInitialFailure = false, ) - .transformWith { - case Failure(ex) => - Future.failed( - new RuntimeException("Failed to connect to scan for batch lookup", ex) - ) - case Success(conn) => - f(conn) - } + .flatMap(f) } class ProcessRewardsTrigger( From b97875f1be3a7fac96217bc633a40702b9cd07d7 Mon Sep 17 00:00:00 2001 From: Divam Date: Wed, 20 May 2026 17:15:25 +0900 Subject: [PATCH 27/40] Throw FAILED_PRECONDITION when waiting on scan Signed-off-by: Divam --- .../automation/confirmation/CalculateRewardsTrigger.scala | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala index cffd286c24..0ec63781bf 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala @@ -31,6 +31,7 @@ import org.lfdecentralizedtrust.splice.util.PrettyInstances.* import com.daml.metrics.api.MetricsContext import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.tracing.TraceContext +import io.grpc.Status import io.opentelemetry.api.trace.Tracer import scala.concurrent.{ExecutionContextExecutor, Future} @@ -66,11 +67,11 @@ abstract class CalculateRewardsTriggerBase( val round = task.calculateRewards.payload.round.number getRootHash(round).flatMap { case None => - Future.successful( - TaskSuccess( + throw Status.FAILED_PRECONDITION + .withDescription( s"waiting for scan to compute root hash for CalculateRewardsV2 round $round, will retry" ) - ) + .asRuntimeException() case Some(rootHash) => val action = startProcessingRewardsAction( task.calculateRewards.contractId, From c7aae0b1c563b3f3911903b858dd53072b1764f8 Mon Sep 17 00:00:00 2001 From: Divam Date: Wed, 20 May 2026 17:19:47 +0900 Subject: [PATCH 28/40] Add TODOs for BFT read Signed-off-by: Divam --- .../sv/automation/confirmation/CalculateRewardsTrigger.scala | 2 ++ .../sv/automation/delegatebased/ProcessRewardsTrigger.scala | 2 ++ 2 files changed, 4 insertions(+) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala index 0ec63781bf..2a63b98052 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala @@ -132,6 +132,7 @@ abstract class CalculateRewardsTriggerBase( .lookupContractById(CalculateRewardsV2.COMPANION)(task.calculateRewards.contractId) .map(_.isEmpty) + // TODO (#5623) replace with non-ephemeral connection private def withScanConnection[T](f: ScanConnection => Future[T])(implicit tc: TraceContext ): Future[T] = @@ -154,6 +155,7 @@ abstract class CalculateRewardsTriggerBase( case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashUndetermined(_) => None case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashCannotProvide(_) => + // TODO (#5623) replace with BFT read throw new RuntimeException( s"Scan cannot provide root hash for round $round" ) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala index f7f6618ae7..537e7565b2 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala @@ -141,12 +141,14 @@ private[delegatebased] abstract class ProcessRewardsTriggerBase( conn.getRewardAccountingBatch(round, batchHash).map { case Some(response) => response case None => + // TODO (#5623) replace with BFT read throw new RuntimeException( s"Batch not found from scan for round $round with hash $batchHash" ) } } + // TODO (#5623) replace with non-ephemeral connection private def withScanConnection[T](f: ScanConnection => Future[T])(implicit tc: TraceContext ): Future[T] = From 8f236377cbc26d81a84c40c2c966d127ce4529b4 Mon Sep 17 00:00:00 2001 From: Divam Date: Wed, 20 May 2026 08:46:09 +0000 Subject: [PATCH 29/40] Use 'mining_round' as it has index Signed-off-by: Divam --- .../lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala | 4 ++-- .../splice/sv/store/db/DbSvDsoStore.scala | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) 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 038034dc78..d02492bc48 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 @@ -1318,7 +1318,7 @@ object SvDsoStore { ) { contract => DsoAcsStoreRowData( contract, - rewardRound = Some(contract.payload.round.number), + miningRound = Some(contract.payload.round.number), ) }, mkFilter(splice.amulet.rewardaccountingv2.ProcessRewardsV2.COMPANION)(co => @@ -1326,7 +1326,7 @@ object SvDsoStore { ) { contract => DsoAcsStoreRowData( contract, - rewardRound = Some(contract.payload.round.number), + miningRound = Some(contract.payload.round.number), ) }, mkFilter(splice.round.OpenMiningRound.COMPANION)(co => co.payload.dso == dso) { contract => diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala index 6ab4f84d6d..bb32fc3232 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala @@ -707,7 +707,7 @@ class DbSvDsoStore( acsStoreId, domainMigrationId, splice.amulet.rewardaccountingv2.CalculateRewardsV2.COMPANION, - orderLimit = sql"""order by reward_round limit ${sqlLimit(limit)}""", + orderLimit = sql"""order by mining_round limit ${sqlLimit(limit)}""", ), "listCalculateRewardsV2", ) @@ -732,7 +732,7 @@ class DbSvDsoStore( acsStoreId, domainMigrationId, splice.amulet.rewardaccountingv2.ProcessRewardsV2.COMPANION, - orderLimit = sql"""order by reward_round limit ${sqlLimit(limit)}""", + orderLimit = sql"""order by mining_round limit ${sqlLimit(limit)}""", ), "listProcessRewardsV2", ) @@ -783,7 +783,7 @@ class DbSvDsoStore( if (rounds.isEmpty) Future.successful((Seq.empty, Seq.empty)) else { - val roundsClause = inClause("reward_round", rounds) + val roundsClause = inClause("mining_round", rounds) val calculateRewardsF = storage .query( selectFromAcsTableWithState( From e7192e17759ce75221e473ae61a622d462997be5 Mon Sep 17 00:00:00 2001 From: Divam Date: Wed, 20 May 2026 17:47:54 +0900 Subject: [PATCH 30/40] Bump DbSvDsoStore version Signed-off-by: Divam --- .../lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala index bb32fc3232..72c47626d6 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala @@ -94,7 +94,7 @@ class DbSvDsoStore( // Any change in the store descriptor will lead to previously deployed applications // forgetting all persisted data once they upgrade to the new version. acsStoreDescriptor = StoreDescriptor( - version = 2, + version = 3, name = "DbSvDsoStore", party = key.dsoParty, participant = participantId, From 196ecd9f322a5f5202d86d57bf11d5496f822687 Mon Sep 17 00:00:00 2001 From: Divam Date: Wed, 20 May 2026 18:04:23 +0900 Subject: [PATCH 31/40] Test: add TODO Signed-off-by: Divam --- .../tests/TrafficBasedRewardsTimeBasedIntegrationTest.scala | 1 + 1 file changed, 1 insertion(+) 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 74712f128f..87b29808a7 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 @@ -281,6 +281,7 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase sv1Backend.archiveDryRunRewardAccountingContracts((0L to 5L).toSeq) } } else { + // TODO (#5624): add support for bootstrapping // Bootstrapping a network with mintingVersion set to trafficBasedAppRewards // is in principle not supported, as the round 0 will never have // activity totals/root-hash calculated, and its CalculateRewardsV2 cannot be processed. From 4399da25c22a331d746a1035fea8faa6afd92579 Mon Sep 17 00:00:00 2001 From: Divam Date: Wed, 20 May 2026 09:10:15 +0000 Subject: [PATCH 32/40] Test: remove wrong assertions Signed-off-by: Divam --- ...icBasedRewardsSvAppTimeBasedIntegrationTest.scala | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala index 4917457629..375a419b96 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala @@ -14,7 +14,6 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletconfig.{ import org.lfdecentralizedtrust.splice.config.ConfigTransforms import org.lfdecentralizedtrust.splice.http.v0.definitions import definitions.GetRewardAccountingBatchResponse -import definitions.GetRewardAccountingActivityTotalsResponse import definitions.GetRewardAccountingRootHashResponse import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.{ @@ -164,17 +163,6 @@ class TrafficBasedRewardsSvAppTimeBasedIntegrationTest } } - clue("Scan computes activity totals even for rounds with no dryRun/mintingVersion set") { - eventually() { - sv1ScanBackend.getRewardAccountingActivityTotals(5L) shouldBe an[ - GetRewardAccountingActivityTotalsResponse.members.RewardAccountingActivityTotalsOk - ] - sv1ScanBackend.getRewardAccountingActivityTotals(7L) shouldBe an[ - GetRewardAccountingActivityTotalsResponse.members.RewardAccountingActivityTotalsOk - ] - } - } - clue("All CalculateRewardsV2 and ProcessRewardsV2 contracts consumed") { eventually() { sv1Backend.appState.dsoStore.listCalculateRewardsV2().futureValue shouldBe empty From 58fa7ad39da4ca8451c6cb3375a90d4a0872916a Mon Sep 17 00:00:00 2001 From: Divam Date: Thu, 21 May 2026 15:15:51 +0900 Subject: [PATCH 33/40] Add listConfirmationsByConfirmer Signed-off-by: Divam --- .../splice/sv/store/SvDsoStore.scala | 7 ++++++ .../splice/sv/store/db/DbSvDsoStore.scala | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+) 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 d02492bc48..11e5e421b4 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 @@ -240,6 +240,13 @@ trait SvDsoStore tc: TraceContext ): Future[Seq[Contract[splice.dsorules.Confirmation.ContractId, splice.dsorules.Confirmation]]] + def listConfirmationsByConfirmer( + confirmer: PartyId, + limit: Limit = defaultLimit, + )(implicit + tc: TraceContext + ): Future[Seq[Contract[splice.dsorules.Confirmation.ContractId, splice.dsorules.Confirmation]]] + def listAppRewardCouponsOnDomain( round: Long, synchronizerId: SynchronizerId, diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala index 72c47626d6..64b9711671 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala @@ -281,6 +281,29 @@ class DbSvDsoStore( } yield limited.map(contractFromRow(Confirmation.COMPANION)(_)) } + override def listConfirmationsByConfirmer( + confirmer: PartyId, + limit: Limit, + )(implicit + tc: TraceContext + ): Future[Seq[Contract[Confirmation.ContractId, Confirmation]]] = waitUntilAcsIngested { + for { + result <- storage + .query( + selectFromAcsTable( + DsoTables.acsTableName, + acsStoreId, + domainMigrationId, + Confirmation.COMPANION, + where = sql"""confirmer = $confirmer""", + orderLimit = sql"""limit ${sqlLimit(limit)}""", + ), + "listConfirmationsByConfirmer", + ) + limited = applyLimit("listConfirmationsByConfirmer", limit, result) + } yield limited.map(contractFromRow(Confirmation.COMPANION)(_)) + } + override def listAppRewardCouponsOnDomain( round: Long, synchronizerId: SynchronizerId, From 9f87a9ee25dbda437a8f9cf2e4be175d333c3dc6 Mon Sep 17 00:00:00 2001 From: Divam Date: Thu, 21 May 2026 15:18:01 +0900 Subject: [PATCH 34/40] Filter confirmed actions in retrieveTasks and isStaleTask Signed-off-by: Divam --- .../CalculateRewardsTrigger.scala | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala index 2a63b98052..e1fde541e7 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala @@ -59,7 +59,11 @@ abstract class CalculateRewardsTriggerBase( override def retrieveTasks()(implicit tc: TraceContext): Future[Seq[Task]] = for { calculateRewards <- store.listCalculateRewardsV2() - } yield calculateRewards.filter(_.payload.dryRun == isDryRun).map(Task(_)) + confirmedCids <- listConfirmedCalculateRewardsCids() + } yield calculateRewards + .filter(_.payload.dryRun == isDryRun) + .filterNot(c => confirmedCids.contains(c.contractId)) + .map(Task(_)) override def completeTask( task: Task @@ -130,7 +134,30 @@ abstract class CalculateRewardsTriggerBase( ): Future[Boolean] = store.multiDomainAcsStore .lookupContractById(CalculateRewardsV2.COMPANION)(task.calculateRewards.contractId) - .map(_.isEmpty) + .flatMap { + case None => Future.successful(true) + case Some(_) => + listConfirmedCalculateRewardsCids().map( + _.contains(task.calculateRewards.contractId) + ) + } + + private def listConfirmedCalculateRewardsCids()(implicit + tc: TraceContext + ): Future[Set[CalculateRewardsV2.ContractId]] = + store.listConfirmationsByConfirmer(svParty).map { confirmations => + confirmations.iterator.flatMap { c => + c.payload.action match { + case arc: ARC_AmuletRules => + arc.amuletRulesAction match { + case crarc: CRARC_StartProcessingRewardsV2 => + Some(crarc.amuletRules_StartProcessingRewardsV2Value.calculateRewardsCid) + case _ => None + } + case _ => None + } + }.toSet + } // TODO (#5623) replace with non-ephemeral connection private def withScanConnection[T](f: ScanConnection => Future[T])(implicit From ae09ba198f0dd32aaade393814bae99f2167193e Mon Sep 17 00:00:00 2001 From: Divam Date: Thu, 21 May 2026 15:24:55 +0900 Subject: [PATCH 35/40] Improve success message Signed-off-by: Divam --- .../delegatebased/ProcessRewardsTrigger.scala | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala index 537e7565b2..bc37b4ba50 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala @@ -112,10 +112,18 @@ private[delegatebased] abstract class ProcessRewardsTriggerBase( MetricsContext.Empty.withExtraLabels("dryRun" -> isDryRun.toString) ) } yield TaskSuccess( - s"Processed batch for ProcessRewardsV2 round $round, batchHash=$batchHash, dryRun=$isDryRun, processingDelay=$delay" + s"Processed round $round, processingDelay=$delay, batchType=${batchTypeOf(batch)}" ) } + private def batchTypeOf(response: GetRewardAccountingBatchResponse): String = + response match { + case _: GetRewardAccountingBatchResponse.members.RewardAccountingBatchOfBatches => + "BatchOfBatches" + case _: GetRewardAccountingBatchResponse.members.RewardAccountingBatchOfMintingAllowances => + "BatchOfMintingAllowances" + } + private def convertBatch( response: GetRewardAccountingBatchResponse ): org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.rewardaccountingv2.Batch = From b61716a933f979a5954a211d6e0358198f2b1526 Mon Sep 17 00:00:00 2001 From: Divam Date: Thu, 21 May 2026 15:35:53 +0900 Subject: [PATCH 36/40] Add comment Signed-off-by: Divam --- .../sv/automation/confirmation/CalculateRewardsTrigger.scala | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala index e1fde541e7..376ee4c3c0 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala @@ -58,6 +58,7 @@ abstract class CalculateRewardsTriggerBase( private val rewardMetrics = new RewardProcessingMetrics(context.metricsFactory) override def retrieveTasks()(implicit tc: TraceContext): Future[Seq[Task]] = for { + // These are ordered by round, so we process the oldest first calculateRewards <- store.listCalculateRewardsV2() confirmedCids <- listConfirmedCalculateRewardsCids() } yield calculateRewards From 3c05f115b4b4b7d84a3fedc8bfed0ea7b5d98a66 Mon Sep 17 00:00:00 2001 From: Divam Date: Fri, 22 May 2026 16:41:13 +0900 Subject: [PATCH 37/40] Add ScanConnection in SvDsoAutomationService for use in triggers Signed-off-by: Divam --- .../DsoDelegateBasedAutomationService.scala | 17 ++--- .../automation/SvDsoAutomationService.scala | 35 ++++++++-- .../CalculateRewardsTrigger.scala | 64 +++++-------------- .../delegatebased/ProcessRewardsTrigger.scala | 60 ++++------------- ...artDsoDelegateBasedAutomationTrigger.scala | 11 +--- .../sv/onboarding/NodeInitializerUtil.scala | 1 + 6 files changed, 67 insertions(+), 121 deletions(-) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/DsoDelegateBasedAutomationService.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/DsoDelegateBasedAutomationService.scala index 6e35409e6d..0cfe95d612 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/DsoDelegateBasedAutomationService.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/DsoDelegateBasedAutomationService.scala @@ -17,14 +17,12 @@ import org.lfdecentralizedtrust.splice.store.{ DomainTimeSynchronization, DomainUnpausedSynchronization, } -import org.lfdecentralizedtrust.splice.config.UpgradesConfig -import org.lfdecentralizedtrust.splice.http.HttpClient +import org.lfdecentralizedtrust.splice.scan.admin.api.client.ScanConnection import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.* import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredAmuletAllocationTrigger -import org.lfdecentralizedtrust.splice.sv.config.{SvAppBackendConfig, SvScanConfig} -import org.lfdecentralizedtrust.splice.util.TemplateJsonDecoder +import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig -import scala.concurrent.ExecutionContextExecutor +import scala.concurrent.{ExecutionContextExecutor, Future} class DsoDelegateBasedAutomationService( clock: Clock, @@ -32,16 +30,13 @@ class DsoDelegateBasedAutomationService( domainUnpausedSync: DomainUnpausedSynchronization, config: SvAppBackendConfig, svTaskContext: SvTaskBasedTrigger.Context, - scanConfig: SvScanConfig, - upgradesConfig: UpgradesConfig, + scanConnectionF: Future[ScanConnection], retryProvider: RetryProvider, override protected val loggerFactory: NamedLoggerFactory, )(implicit ec: ExecutionContextExecutor, mat: Materializer, tracer: Tracer, - httpClient: HttpClient, - templateJsonDecoder: TemplateJsonDecoder, ) extends AutomationService( config.automation, clock, @@ -149,10 +144,10 @@ class DsoDelegateBasedAutomationService( ) registerTrigger( - new ProcessRewardsTrigger(triggerContext, svTaskContext, scanConfig, upgradesConfig) + new ProcessRewardsTrigger(triggerContext, svTaskContext, scanConnectionF) ) registerTrigger( - new ProcessRewardsDryRunTrigger(triggerContext, svTaskContext, scanConfig, upgradesConfig) + new ProcessRewardsDryRunTrigger(triggerContext, svTaskContext, scanConnectionF) ) } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala index 6a4c2e3eb8..f5e45f120a 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala @@ -6,10 +6,12 @@ package org.lfdecentralizedtrust.splice.sv.automation import cats.implicits.catsSyntaxOptionId import com.daml.grpc.adapter.ExecutionSequencerFactory import com.digitalasset.canton.SynchronizerAlias -import com.digitalasset.canton.config.ClientConfig +import com.digitalasset.canton.config.{ClientConfig, NonNegativeDuration} +import com.digitalasset.canton.lifecycle.{AsyncCloseable, AsyncOrSyncCloseable} import com.digitalasset.canton.logging.NamedLoggerFactory import com.digitalasset.canton.time.{Clock, WallClock} import com.digitalasset.canton.topology.SynchronizerId +import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer import monocle.Monocle.toAppliedFocusOps import org.apache.pekko.stream.Materializer @@ -24,11 +26,14 @@ import org.lfdecentralizedtrust.splice.automation.AutomationServiceCompanion.{ } import org.lfdecentralizedtrust.splice.config.{ EnabledFeaturesConfig, + NetworkAppClientConfig, SpliceInstanceNamesConfig, UpgradesConfig, } import org.lfdecentralizedtrust.splice.environment.* import org.lfdecentralizedtrust.splice.http.HttpClient +import org.lfdecentralizedtrust.splice.scan.admin.api.client.ScanConnection +import org.lfdecentralizedtrust.splice.scan.config.ScanAppClientConfig import org.lfdecentralizedtrust.splice.store.{ DomainTimeSynchronization, DomainUnpausedSynchronization, @@ -59,7 +64,7 @@ import org.lfdecentralizedtrust.splice.sv.onboarding.SynchronizerNodeReconciler import org.lfdecentralizedtrust.splice.sv.store.{SvDsoStore, SvSvStore} import org.lfdecentralizedtrust.splice.util.TemplateJsonDecoder -import scala.concurrent.ExecutionContextExecutor +import scala.concurrent.{ExecutionContextExecutor, Future} class SvDsoAutomationService( clock: Clock, @@ -87,6 +92,7 @@ class SvDsoAutomationService( httpClient: HttpClient, templateJsonDecoder: TemplateJsonDecoder, esf: ExecutionSequencerFactory, + tc: TraceContext, ) extends SpliceAppAutomationService( config.automation, clock, @@ -102,6 +108,23 @@ class SvDsoAutomationService( : org.lfdecentralizedtrust.splice.sv.automation.SvDsoAutomationService.type = SvDsoAutomationService + // Shared long-lived connection to the SV's own scan, used by the reward triggers + private val scanConnectionF: Future[ScanConnection] = ScanConnection.singleUncached( + ScanAppClientConfig(NetworkAppClientConfig(config.scan.internalUrl)), + upgradesConfig, + clock, + retryProvider, + loggerFactory, + retryConnectionOnInitialFailure = true, + ) + + override protected def closeAsync(): Seq[AsyncOrSyncCloseable] = + super.closeAsync() :+ AsyncCloseable( + "scan-connection", + scanConnectionF.map(_.close()), + NonNegativeDuration.tryFromDuration(timeouts.shutdownNetwork.duration), + ) + private val packageVettingService = new PackageVettingLookupService( config.packageVettingCache, connection( @@ -128,7 +151,7 @@ class SvDsoAutomationService( retryProvider, packageVersionSupport, packageVettingService, - upgradesConfig, + scanConnectionF, ) // required for triggers that must run in sim time as well @@ -370,8 +393,7 @@ class SvDsoAutomationService( triggerContext, dsoStore, connection(SpliceLedgerConnectionPriority.Medium), - config.scan, - upgradesConfig, + scanConnectionF, ) ) @@ -380,8 +402,7 @@ class SvDsoAutomationService( triggerContext, dsoStore, connection(SpliceLedgerConnectionPriority.Medium), - config.scan, - upgradesConfig, + scanConnectionF, ) ) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala index 376ee4c3c0..2c8dc46288 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala @@ -17,16 +17,12 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.AmuletRul import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.ActionRequiringConfirmation import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.actionrequiringconfirmation.ARC_AmuletRules import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.amuletrules_actionrequiringconfirmation.CRARC_StartProcessingRewardsV2 -import org.lfdecentralizedtrust.splice.config.{NetworkAppClientConfig, UpgradesConfig} import org.lfdecentralizedtrust.splice.environment.SpliceLedgerConnection -import org.lfdecentralizedtrust.splice.http.HttpClient import org.lfdecentralizedtrust.splice.scan.admin.api.client.ScanConnection -import org.lfdecentralizedtrust.splice.scan.config.ScanAppClientConfig import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.QueryResult import org.lfdecentralizedtrust.splice.sv.automation.RewardProcessingMetrics -import org.lfdecentralizedtrust.splice.sv.config.SvScanConfig import org.lfdecentralizedtrust.splice.sv.store.SvDsoStore -import org.lfdecentralizedtrust.splice.util.{AssignedContract, TemplateJsonDecoder} +import org.lfdecentralizedtrust.splice.util.AssignedContract import org.lfdecentralizedtrust.splice.util.PrettyInstances.* import com.daml.metrics.api.MetricsContext import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} @@ -40,15 +36,12 @@ abstract class CalculateRewardsTriggerBase( override protected val context: TriggerContext, store: SvDsoStore, connection: SpliceLedgerConnection, - scanConfig: SvScanConfig, - upgradesConfig: UpgradesConfig, + scanConnectionF: Future[ScanConnection], isDryRun: Boolean, )(implicit ec: ExecutionContextExecutor, mat: Materializer, tracer: Tracer, - httpClient: HttpClient, - templateJsonDecoder: TemplateJsonDecoder, ) extends PollingParallelTaskExecutionTrigger[CalculateRewardsTriggerBase.Task] { import CalculateRewardsTriggerBase.* @@ -160,34 +153,17 @@ abstract class CalculateRewardsTriggerBase( }.toSet } - // TODO (#5623) replace with non-ephemeral connection - private def withScanConnection[T](f: ScanConnection => Future[T])(implicit - tc: TraceContext - ): Future[T] = - ScanConnection - .singleUncached( - ScanAppClientConfig(NetworkAppClientConfig(scanConfig.internalUrl)), - upgradesConfig, - context.clock, - context.retryProvider, - loggerFactory, - retryConnectionOnInitialFailure = false, - ) - .flatMap(f) - private def getRootHash(round: Long)(implicit tc: TraceContext): Future[Option[Hash]] = - withScanConnection { conn => - conn.getRewardAccountingRootHash(round).map { - case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(ok) => - Some(new Hash(ok.rootHash)) - case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashUndetermined(_) => - None - case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashCannotProvide(_) => - // TODO (#5623) replace with BFT read - throw new RuntimeException( - s"Scan cannot provide root hash for round $round" - ) - } + scanConnectionF.flatMap(_.getRewardAccountingRootHash(round)).map { + case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(ok) => + Some(new Hash(ok.rootHash)) + case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashUndetermined(_) => + None + case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashCannotProvide(_) => + // TODO (#5623) replace with BFT read + throw new RuntimeException( + s"Scan cannot provide root hash for round $round" + ) } private def startProcessingRewardsAction( @@ -209,20 +185,16 @@ class CalculateRewardsTrigger( override protected val context: TriggerContext, store: SvDsoStore, connection: SpliceLedgerConnection, - scanConfig: SvScanConfig, - upgradesConfig: UpgradesConfig, + scanConnectionF: Future[ScanConnection], )(implicit ec: ExecutionContextExecutor, mat: Materializer, tracer: Tracer, - httpClient: HttpClient, - templateJsonDecoder: TemplateJsonDecoder, ) extends CalculateRewardsTriggerBase( context, store, connection, - scanConfig, - upgradesConfig, + scanConnectionF, isDryRun = false, ) @@ -230,20 +202,16 @@ class CalculateRewardsDryRunTrigger( override protected val context: TriggerContext, store: SvDsoStore, connection: SpliceLedgerConnection, - scanConfig: SvScanConfig, - upgradesConfig: UpgradesConfig, + scanConnectionF: Future[ScanConnection], )(implicit ec: ExecutionContextExecutor, mat: Materializer, tracer: Tracer, - httpClient: HttpClient, - templateJsonDecoder: TemplateJsonDecoder, ) extends CalculateRewardsTriggerBase( context, store, connection, - scanConfig, - upgradesConfig, + scanConnectionF, isDryRun = true, ) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala index bc37b4ba50..8537f4e278 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala @@ -22,18 +22,14 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.rewardaccounti BatchOfBatches, BatchOfMintingAllowances, } -import org.lfdecentralizedtrust.splice.config.{NetworkAppClientConfig, UpgradesConfig} -import org.lfdecentralizedtrust.splice.http.HttpClient import org.lfdecentralizedtrust.splice.http.v0.definitions.{ GetRewardAccountingBatchResponse, RewardAccountingMintingAllowance, } import org.lfdecentralizedtrust.splice.scan.admin.api.client.ScanConnection -import org.lfdecentralizedtrust.splice.scan.config.ScanAppClientConfig import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority import org.lfdecentralizedtrust.splice.sv.automation.RewardProcessingMetrics -import org.lfdecentralizedtrust.splice.sv.config.SvScanConfig -import org.lfdecentralizedtrust.splice.util.{AssignedContract, TemplateJsonDecoder} +import org.lfdecentralizedtrust.splice.util.AssignedContract import com.daml.metrics.api.MetricsContext import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer @@ -48,15 +44,12 @@ import ProcessRewardsTriggerBase.* private[delegatebased] abstract class ProcessRewardsTriggerBase( override protected val context: TriggerContext, override protected val svTaskContext: SvTaskBasedTrigger.Context, - scanConfig: SvScanConfig, - upgradesConfig: UpgradesConfig, + scanConnectionF: Future[ScanConnection], isDryRun: Boolean, )(implicit ec: ExecutionContextExecutor, mat: Materializer, tracer: Tracer, - httpClient: HttpClient, - templateJsonDecoder: TemplateJsonDecoder, ) extends OnAssignedContractTrigger.Template[ ProcessRewardsV2.ContractId, ProcessRewardsV2, @@ -145,70 +138,43 @@ private[delegatebased] abstract class ProcessRewardsTriggerBase( private def fetchBatch(round: Long, batchHash: String)(implicit tc: TraceContext ): Future[GetRewardAccountingBatchResponse] = - withScanConnection { conn => - conn.getRewardAccountingBatch(round, batchHash).map { - case Some(response) => response - case None => - // TODO (#5623) replace with BFT read - throw new RuntimeException( - s"Batch not found from scan for round $round with hash $batchHash" - ) - } + scanConnectionF.flatMap(_.getRewardAccountingBatch(round, batchHash)).map { + case Some(response) => response + case None => + // TODO (#5623) replace with BFT read + throw new RuntimeException( + s"Batch not found from scan for round $round with hash $batchHash" + ) } - - // TODO (#5623) replace with non-ephemeral connection - private def withScanConnection[T](f: ScanConnection => Future[T])(implicit - tc: TraceContext - ): Future[T] = - ScanConnection - .singleUncached( - ScanAppClientConfig( - NetworkAppClientConfig(scanConfig.internalUrl) - ), - upgradesConfig, - context.clock, - context.retryProvider, - loggerFactory, - retryConnectionOnInitialFailure = false, - ) - .flatMap(f) } class ProcessRewardsTrigger( override protected val context: TriggerContext, override protected val svTaskContext: SvTaskBasedTrigger.Context, - scanConfig: SvScanConfig, - upgradesConfig: UpgradesConfig, + scanConnectionF: Future[ScanConnection], )(implicit ec: ExecutionContextExecutor, mat: Materializer, tracer: Tracer, - httpClient: HttpClient, - templateJsonDecoder: TemplateJsonDecoder, ) extends ProcessRewardsTriggerBase( context, svTaskContext, - scanConfig, - upgradesConfig, + scanConnectionF, isDryRun = false, ) class ProcessRewardsDryRunTrigger( override protected val context: TriggerContext, override protected val svTaskContext: SvTaskBasedTrigger.Context, - scanConfig: SvScanConfig, - upgradesConfig: UpgradesConfig, + scanConnectionF: Future[ScanConnection], )(implicit ec: ExecutionContextExecutor, mat: Materializer, tracer: Tracer, - httpClient: HttpClient, - templateJsonDecoder: TemplateJsonDecoder, ) extends ProcessRewardsTriggerBase( context, svTaskContext, - scanConfig, - upgradesConfig, + scanConnectionF, isDryRun = true, ) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/RestartDsoDelegateBasedAutomationTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/RestartDsoDelegateBasedAutomationTrigger.scala index cb5f3da5f9..0eae3058f9 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/RestartDsoDelegateBasedAutomationTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/RestartDsoDelegateBasedAutomationTrigger.scala @@ -11,15 +11,13 @@ import org.lfdecentralizedtrust.splice.automation.{ TriggerContext, } import org.lfdecentralizedtrust.splice.codegen.java.splice -import org.lfdecentralizedtrust.splice.config.UpgradesConfig import org.lfdecentralizedtrust.splice.environment.{ PackageVersionSupport, PackageVettingLookupService, RetryProvider, SpliceLedgerConnection, } -import org.lfdecentralizedtrust.splice.http.HttpClient -import org.lfdecentralizedtrust.splice.util.TemplateJsonDecoder +import org.lfdecentralizedtrust.splice.scan.admin.api.client.ScanConnection import org.lfdecentralizedtrust.splice.store.{ DomainTimeSynchronization, DomainUnpausedSynchronization, @@ -53,13 +51,11 @@ class RestartDsoDelegateBasedAutomationTrigger( appLevelRetryProvider: RetryProvider, packageVersionSupport: PackageVersionSupport, packageVettingService: PackageVettingLookupService, - upgradesConfig: UpgradesConfig, + scanConnectionF: Future[ScanConnection], )(implicit override val ec: ExecutionContextExecutor, mat: Materializer, tracer: Tracer, - httpClient: HttpClient, - templateJsonDecoder: TemplateJsonDecoder, ) extends OnAssignedContractTrigger.Template[ splice.dsorules.DsoRules.ContractId, splice.dsorules.DsoRules, @@ -168,8 +164,7 @@ class RestartDsoDelegateBasedAutomationTrigger( domainUnpausedSync, config, svTaskContext, - config.scan, - upgradesConfig, + scanConnectionF, retryProvider, loggerFactory, ) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/NodeInitializerUtil.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/NodeInitializerUtil.scala index c56447738b..e9ed3f1f13 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/NodeInitializerUtil.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/NodeInitializerUtil.scala @@ -151,6 +151,7 @@ trait NodeInitializerUtil extends NamedLogging with Spanning with SynchronizerNo httpClient: HttpClient, templateJsonDecoder: TemplateJsonDecoder, esf: ExecutionSequencerFactory, + tc: TraceContext, ) = new SvDsoAutomationService( clock, From 025cd67746ee9d76998b8db0fedad5796aa94069 Mon Sep 17 00:00:00 2001 From: Divam Date: Thu, 28 May 2026 13:28:56 +0900 Subject: [PATCH 38/40] Fixes per suggestions Signed-off-by: Divam --- .../confirmation/CalculateRewardsTrigger.scala | 9 +++------ .../automation/delegatebased/ProcessRewardsTrigger.scala | 1 + 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala index 2c8dc46288..b40f7a104b 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala @@ -6,6 +6,7 @@ package org.lfdecentralizedtrust.splice.sv.automation.confirmation import org.apache.pekko.stream.Materializer import org.lfdecentralizedtrust.splice.automation.{ PollingParallelTaskExecutionTrigger, + TaskNoop, TaskOutcome, TaskSuccess, TriggerContext, @@ -67,7 +68,7 @@ abstract class CalculateRewardsTriggerBase( case None => throw Status.FAILED_PRECONDITION .withDescription( - s"waiting for scan to compute root hash for CalculateRewardsV2 round $round, will retry" + s"Scan has not yet computed the root hash for CalculateRewardsV2 round $round." ) .asRuntimeException() case Some(rootHash) => @@ -79,11 +80,7 @@ abstract class CalculateRewardsTriggerBase( queryResult <- store.lookupConfirmationByActionWithOffset(svParty, action) taskOutcome <- queryResult match { case QueryResult(_, Some(_)) => - Future.successful( - TaskSuccess( - s"skipping as confirmation from $svParty is already created for CalculateRewardsV2 round $round" - ) - ) + Future.successful(TaskNoop) case QueryResult(offset, None) => for { dsoRules <- store.getDsoRules() diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala index 8537f4e278..449955cc36 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala @@ -81,6 +81,7 @@ private[delegatebased] abstract class ProcessRewardsTriggerBase( damlBatch = convertBatch(batch) choiceArg = new ProcessRewardsV2_ProcessBatch( damlBatch, + // TODO (#5715) determine 'providersWithWrongVettingState' new DamlSet(java.util.Collections.emptyMap()), ) cmd = dsoRules.exercise( From 7823e8a23c985562b82bfccfbd47b6dab6b5eb43 Mon Sep 17 00:00:00 2001 From: Divam Date: Thu, 28 May 2026 13:29:17 +0900 Subject: [PATCH 39/40] [ci] Signed-off-by: Divam From 1f7a70e5f3bdec5d4da436c92ba0e2b1d95a124c Mon Sep 17 00:00:00 2001 From: Divam Date: Thu, 28 May 2026 15:03:27 +0900 Subject: [PATCH 40/40] [ci] fix closeAsync Signed-off-by: Divam --- .../splice/sv/automation/SvDsoAutomationService.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala index f5e45f120a..d17a311bf3 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala @@ -121,7 +121,10 @@ class SvDsoAutomationService( override protected def closeAsync(): Seq[AsyncOrSyncCloseable] = super.closeAsync() :+ AsyncCloseable( "scan-connection", - scanConnectionF.map(_.close()), + scanConnectionF.transform { + case scala.util.Success(c) => scala.util.Try(c.close()) + case scala.util.Failure(_) => scala.util.Success(()) + }, NonNegativeDuration.tryFromDuration(timeouts.shutdownNetwork.duration), )