From 3b918999197ddcfec0a83259f9984ee756ca37dc Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Fri, 15 May 2026 14:58:27 +0000 Subject: [PATCH 01/19] draft initial commit (no daml tests) Signed-off-by: Jose Velasco --- .../daml/Splice/DsoRules.daml | 234 +++++++++++++++++- .../daml/Splice/SvWeight.daml | 35 +++ 2 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 daml/splice-dso-governance/daml/Splice/SvWeight.daml diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 5db7ad0b6e..5f9c8b6f5b 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -33,6 +33,7 @@ import Splice.SvOnboarding import Splice.DSO.AmuletPrice import Splice.DSO.DecentralizedSynchronizer import Splice.DSO.SvState +import Splice.SvWeight import Splice.Schedule import Splice.Util import Splice.CometBft() @@ -115,6 +116,12 @@ data DsoRules_ActionRequiringConfirmation -- ^ Voted action to create an UnallocatedUnclaimedActivityRecord contract. | SRARC_CreateBootstrapExternalPartyConfigStateInstruction DsoRules_CreateBootstrapExternalPartyConfigStateInstruction -- ^ Create BootstrapExternalPartyConfigStateInstruction + | SRARC_AddSvRewardWeight DsoRules_AddSvRewardWeight + -- ^ Voted action to add the weight of an SV. + | SRARC_UpdateSvRewardWeight_V2 DsoRules_UpdateSvRewardWeight_V2 + -- ^ Voted action to add update the weight of an SV. + | SRARC_RemoveSvRewardWeight DsoRules_RemoveSvRewardWeight + -- ^ Voted action to remove the weight of an SV. deriving (Eq, Show) data AnsEntryContext_ActionRequiringConfirmation @@ -128,7 +135,7 @@ data AnsEntryContext_ActionRequiringConfirmation data SvInfo = SvInfo with name : Text -- ^ Human-readable name; must be unique. joinedAsOfRound : Round -- ^ Round in which the SV joined - svRewardWeight : Int -- ^ Weight of the SV in the SV reward distribution. + svRewardWeight : Int -- ^ __Deprecated__ in favor of `SvWeight`. participantId : Text -- ^ Participant ID of the SV, stored here as PartyToParticipant mappings are tracked via state on the DsoRules + SvOnboardingConfirmed contracts. deriving (Eq, Show) @@ -156,6 +163,9 @@ data Confirmation_ExpireResult = Confirmation_ExpireResult data DsoRules_AddSvResult = DsoRules_AddSvResult with newDsoRules : ContractId DsoRules +data DsoRules_AddSvOperatorResult = DsoRules_AddSvOperatorResult with + newDsoRules : ContractId DsoRules + data DsoRules_OffboardSvResult = DsoRules_OffboardSvResult with newDsoRules : ContractId DsoRules @@ -200,6 +210,18 @@ data DsoRules_SetConfigResult = DsoRules_SetConfigResult with data DsoRules_UpdateSvRewardWeightResult = DsoRules_UpdateSvRewardWeightResult with newDsoRules : ContractId DsoRules +data DsoRules_UpdateSvRewardWeight_V2Result = DsoRules_UpdateSvRewardWeight_V2Result with + svWeightCid : ContractId SvWeight + +data DsoRules_AddSvRewardWeightResult = DsoRules_AddSvRewardWeightResult with + svWeightCid : ContractId SvWeight + +data DsoRules_RemoveSvRewardWeightResult = DsoRules_RemoveSvRewardWeightResult + +data DsoRules_MigrateSvWeightsResult = DsoRules_MigrateSvWeightsResult with + svWeightCids : [ContractId SvWeight] + newDsoRules : ContractId DsoRules + data DsoRules_GrantFeaturedAppRightResult = DsoRules_GrantFeaturedAppRightResult with featuredAppRight : ContractId FeaturedAppRight @@ -512,6 +534,7 @@ template DsoRules with -- Sv management ------------------------ + -- TODO: Deprecate choice DsoRules_AddSv : DsoRules_AddSvResult with newSvParty : Party @@ -520,8 +543,22 @@ template DsoRules with newSvParticipantId : Text joinedAsOfRound : Round controller dso - do newDsoRules <- dsoRules_addSv this arg - return DsoRules_AddSvResult with .. + do + do newDsoRules <- dsoRules_addSv this arg + return DsoRules_AddSvResult with .. + + choice DsoRules_AddSvOperator : DsoRules_AddSvOperatorResult + with + newSvParty : Party + -- DRAFT note for the reviewer: In the future, newSvName should not be used, since an SV operator may operate multiple SVs. + -- Removing newSvName in this proposal would require modifying the voting flows to accept votes for all SVs (SVs operating a node and hosted SVs), + -- which is not a requirement in the current proposal. + newSvName : Text + newSvParticipantId : Text + joinedAsOfRound : Round + controller dso + do newDsoRules <- dsoRules_AddSvOperator this arg + return DsoRules_AddSvOperatorResult with .. choice DsoRules_OffboardSv : DsoRules_OffboardSvResult with @@ -962,6 +999,11 @@ template DsoRules with create this with config = patch newConfig baseConfig this.config return DsoRules_SetConfigResult with .. + + -- SV weight management + ----------------------- + + -- TODO: Deprecate once all SVs have migrated to SvWeight contracts. choice DsoRules_UpdateSvRewardWeight : DsoRules_UpdateSvRewardWeightResult with svParty : Party @@ -977,6 +1019,74 @@ template DsoRules with newDsoRules <- create this with svs = newSvs return DsoRules_UpdateSvRewardWeightResult with .. + choice DsoRules_UpdateSvRewardWeight_V2 : DsoRules_UpdateSvRewardWeight_V2Result + with + svWeightCid : ContractId SvWeight + newRewardWeight : Int + controller dso + do + svWeight <- fetchAndArchive (ForDso with dso) svWeightCid + svWeightCid <- create svWeight with + weight = newRewardWeight + pure DsoRules_UpdateSvRewardWeight_V2Result with svWeightCid + + nonconsuming choice DsoRules_AddSvRewardWeight : DsoRules_AddSvRewardWeightResult + with + svOperator : Party + sv : Party + rewardWeight : Int + controller dso + do + -- Sanity checks + let isSvOperator = flip Map.member svs + require "svOperator is an SV operator" $ isSvOperator svOperator + + svWeightCid <- create SvWeight with dso; sv; svOperator; weight = rewardWeight + pure DsoRules_AddSvRewardWeightResult with svWeightCid + + nonconsuming choice DsoRules_RemoveSvRewardWeight : DsoRules_RemoveSvRewardWeightResult + with + svWeightCid : ContractId SvWeight + controller dso + do + _ <- fetchAndArchive (ForDso with dso) svWeightCid + pure DsoRules_RemoveSvRewardWeightResult + + -- TODO: Deprecate once all SVs have migrated to SvWeight contracts. + -- DRAFT note for the reviewer: Please, check migration procedure in the PR description. + choice DsoRules_MigrateSvWeights : DsoRules_MigrateSvWeightsResult + with + -- Complete on-ledger reward weight distribution for the SVs operated by this SV operator. + -- The sum of these weights must match the SV operator's current legacy reward weight. + -- Extra beneficiaries used only for internal reward sharing must not be migrated as SvWeights; + -- their share should be included in the corresponding SV's weight and configured separately + -- as beneficiary splits in the SV app. + svWeights : [(Party, Int)] + sv : Party + controller sv + do + case Map.lookup sv svs of + None -> fail "Not a sv" + Some svInfo -> do + -- Sanity checks + let (svParties, rewardWeights) = unzip svWeights + require "Total SV weights match SV operator weight" (sum rewardWeights == svInfo.svRewardWeight) + require "SVs are unique" (unique svParties) + + -- Invalidate the SV operator's legacy reward weight. + let newSv = svInfo with svRewardWeight = 0 + newSvs = Map.insert sv newSv svs + newDsoRules <- create this with svs = newSvs + + -- Create hosted SVs weigths + svWeightCids <- forA svWeights $ \(svParty, rewardWeight) -> + create SvWeight with + dso + sv = svParty + svOperator = sv + weight = rewardWeight + + pure DsoRules_MigrateSvWeightsResult with svWeightCids; newDsoRules -- App rights management @@ -1299,6 +1409,7 @@ template DsoRules with -- Reward management driven directly by the DSO delegates --------------------------------------------------------- + -- TODO: Deprecate once all SVs have migrated to SvWeight contracts. nonconsuming choice DsoRules_ReceiveSvRewardCoupon : DsoRules_ReceiveSvRewardCouponResult with sv : Party @@ -1347,6 +1458,67 @@ template DsoRules with svRewardState = newRewardStateCid svRewardCoupons = couponCids + nonconsuming choice DsoRules_ReceiveSvRewardCoupon_V2 : DsoRules_ReceiveSvRewardCouponResult + with + sv : Party + openRoundCid : ContractId OpenMiningRound + rewardStateCid : ContractId SvRewardState + -- All SvWeights for SVs operated by this SV operator, each with an optional beneficiary split. + -- If the beneficiary list is empty, the SvWeight's SV receives one coupon with the full weight. + -- Otherwise, one coupon is issued per listed beneficiary, and the listed weights must sum to the SvWeight weight. + svWeights : [(ContractId SvWeight, [(Party, Int)])] + controller sv + do + case Map.lookup sv svs of + None -> fail "SV is not an SV" + Some info -> do + -- check round + now <- getTime + openRound <- fetchReferenceData (ForDso with dso) openRoundCid + require "OpenRound is open" (openRound.opensAt <= now) + + beneficiariesPerSvWithSv <- forA svWeights $ \(svWeightCid, beneficiaries) -> do + svWeight <- fetchChecked (ForOwner with dso; owner = sv) svWeightCid + let defaultBeneficiaries = [(svWeight.sv, svWeight.weight)] + case beneficiaries of + [] -> pure (defaultBeneficiaries, svWeight.sv) + _ -> do + require "Total beneficiary weight matches SV weight" $ sum (map snd beneficiaries) == svWeight.weight + require "At most one coupon per beneficiary" (unique $ map fst beneficiaries) + pure (beneficiaries, svWeight.sv) + + let + (beneficiariesPerSv, svs) = unzip beneficiariesPerSvWithSv + allBeneficiaries = concat beneficiariesPerSv + require "Each SV weight can be used at most once" (unique svs) + + -- check and update state + rewardState <- fetchAndArchive (ForSv with dso; svName = info.name) rewardStateCid + let state = rewardState.state + require + ("Round " <> show openRound.round <> " is greater than the last round a reward has been received for " <> show state.lastRoundCollected) + (state.lastRoundCollected < openRound.round) + + newRewardStateCid <- create rewardState with + state = RewardState with + lastRoundCollected = openRound.round + numRoundsCollected = state.numRoundsCollected + 1 + numRoundsMissed = + state.numRoundsMissed + (openRound.round.number - state.lastRoundCollected.number - 1) + numCouponsIssued = state.numCouponsIssued + length allBeneficiaries + + couponCids <- forA allBeneficiaries $ \(beneficiary, weight) -> + create SvRewardCoupon with + dso + sv + beneficiary + weight + round = openRound.round + + return DsoRules_ReceiveSvRewardCouponResult with + svRewardState = newRewardStateCid + svRewardCoupons = couponCids + -- Batch expiry of unclaimed rewards for a specific claimed round ----------------------------------------------------------------- @@ -1714,6 +1886,9 @@ executeActionRequiringConfirmation dso dsoRulesCid amuletRulesCid act = case act SRARC_CreateTransferCommandCounter choiceArg -> void $ exercise dsoRulesCid choiceArg SRARC_CreateUnallocatedUnclaimedActivityRecord choiceArg -> void $ exercise dsoRulesCid choiceArg SRARC_CreateBootstrapExternalPartyConfigStateInstruction choiceArg -> void $ exercise dsoRulesCid choiceArg + SRARC_AddSvRewardWeight choiceArg -> void $ exercise dsoRulesCid choiceArg + SRARC_UpdateSvRewardWeight_V2 choiceArg -> void $ exercise dsoRulesCid choiceArg + SRARC_RemoveSvRewardWeight choiceArg -> void $ exercise dsoRulesCid choiceArg ARC_AnsEntryContext with .. -> do void $ fetchChecked (ForDso with dso) ansEntryContextCid case ansEntryContextAction of @@ -1795,6 +1970,44 @@ ensureNeverOperatedNode newSvParty this = do require "SV party has not yet operated a node" $ not (newSvParty `Map.member` this.svs || newSvParty `Map.member` this.offboardedSvs) +-- factored out from the choice to avoid mistakes from having the fields of DsoRules{..} in scope +dsoRules_AddSvOperator : DsoRules -> DsoRules_AddSvOperator -> Update (ContractId DsoRules) +dsoRules_AddSvOperator this0 arg@DsoRules_AddSvOperator{..} = do + ensureNeverOperatedNode newSvParty this0 + -- in DevNet we allow changing the operator of an existing sv, which we implement as a removal and re-addition + this@DsoRules{..} <- + if this0.isDevNet + then + let DsoRules{..} = this0 in -- bring all DsoRules fields into scope + case lookupSvInfoByName newSvName this0 of + None -> pure this0 + Some (sv, info) -> do + let offboardingInfo = OffboardedSvInfo with + name = info.name + participantId = info.participantId + pure $ this0 with + svs = Map.delete sv svs + offboardedSvs = Map.insert sv offboardingInfo offboardedSvs + else do + require "SV is not currently onboarded" (isNone $ lookupSvInfoByName newSvName this0) + pure this0 + + -- create per-sv contracts if they have never been onboarded + unless (svHasBeenOnboardedBefore newSvName this) $ + createPerSvContracts' this arg + -- create per SV party contracts + let initialAmuletPriceVote = None + createPerSvPartyContracts dso newSvParty newSvName noSynchronizerNodes initialAmuletPriceVote (getVoteCooldownTime config) + -- register the new operator in the DsoRules + let svInfo = SvInfo with + name = newSvName + joinedAsOfRound + -- Set to zero, as the SV weights are represented as `SvWeight` contracts + svRewardWeight = 0 + participantId = newSvParticipantId + create this with + svs = Map.insert newSvParty svInfo svs + -- factored out from the choice to avoid mistakes from having the fields of DsoRules{..} in scope dsoRules_addSv : DsoRules -> DsoRules_AddSv -> Update (ContractId DsoRules) dsoRules_addSv this0 arg@DsoRules_AddSv{..} = do @@ -1832,8 +2045,8 @@ dsoRules_addSv this0 arg@DsoRules_AddSv{..} = do create this with svs = Map.insert newSvParty svInfo svs -createPerSvContracts : DsoRules -> DsoRules_AddSv -> Update () -createPerSvContracts DsoRules{..} DsoRules_AddSv{..} = do +createPerSvContracts' : DsoRules -> DsoRules_AddSvOperator -> Update () +createPerSvContracts' DsoRules{..} DsoRules_AddSvOperator{..} = do void $ create SvRewardState with dso svName = newSvName @@ -1868,6 +2081,17 @@ createPerSvPartyContracts dso newSvParty newSvName synchronizerNodes amuletPrice state = NodeState with synchronizerNodes +createPerSvContracts : DsoRules -> DsoRules_AddSv -> Update () +createPerSvContracts DsoRules{..} DsoRules_AddSv{..} = do + void $ create SvRewardState with + dso + svName = newSvName + state = RewardState with + lastRoundCollected = Round (joinedAsOfRound.number - 1) + numRoundsMissed = 0 + numRoundsCollected = 0 + numCouponsIssued = 0 + getAndValidateSvParty : DsoRules -> Optional Party -> Update Party getAndValidateSvParty _ None = fail "no SV party provided" getAndValidateSvParty rules (Some sv) = do diff --git a/daml/splice-dso-governance/daml/Splice/SvWeight.daml b/daml/splice-dso-governance/daml/Splice/SvWeight.daml new file mode 100644 index 0000000000..fd1a5d7e79 --- /dev/null +++ b/daml/splice-dso-governance/daml/Splice/SvWeight.daml @@ -0,0 +1,35 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +module Splice.SvWeight where + +import Splice.Types +import Splice.Util + + +-- | On-ledger representation of the SV weight. +-- +-- A 'SvWeight' links a SV party to a SV operator that records +-- the reward weight used to create SV reward coupons for the SV. +template SvWeight + with + dso : Party + sv : Party -- ^ The SV party for which reward coupons are created by the SV operator. + svOperator : Party -- ^ The SV operator party responsible for creating reward coupons for 'sv'. + weight : Int -- ^ The SV reward weight. Must be strictly greater than 0. + where + ensure weight > 0 + signatory dso, svOperator + + observer sv + + + +-- instances +------------ + +instance HasCheckedFetch SvWeight ForOwner where + contractGroupId SvWeight{..} = ForOwner with dso; owner = sv + +instance HasCheckedFetch SvWeight ForDso where + contractGroupId SvWeight{..} = ForDso with dso \ No newline at end of file From 49d2893139cd85cb2f7a86b429e980ed3e8d46bc Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Fri, 15 May 2026 17:02:55 +0100 Subject: [PATCH 02/19] Update daml/splice-dso-governance/daml/Splice/DsoRules.daml Co-authored-by: Itai Segall Signed-off-by: Jose Velasco --- daml/splice-dso-governance/daml/Splice/DsoRules.daml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 5f9c8b6f5b..f00189fed2 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -119,7 +119,7 @@ data DsoRules_ActionRequiringConfirmation | SRARC_AddSvRewardWeight DsoRules_AddSvRewardWeight -- ^ Voted action to add the weight of an SV. | SRARC_UpdateSvRewardWeight_V2 DsoRules_UpdateSvRewardWeight_V2 - -- ^ Voted action to add update the weight of an SV. + -- ^ Voted action to update the weight of an SV. | SRARC_RemoveSvRewardWeight DsoRules_RemoveSvRewardWeight -- ^ Voted action to remove the weight of an SV. deriving (Eq, Show) From fed0180a302bed546358a731fc566f6eed271e74 Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Tue, 19 May 2026 15:47:06 +0000 Subject: [PATCH 03/19] replace SvWeight contracts with hostedSvs Map in DsoRules Signed-off-by: Jose Velasco --- .../daml/Splice/DSO/SvState.daml | 3 + .../daml/Splice/DsoBootstrap.daml | 1 + .../daml/Splice/DsoRules.daml | 351 +++++++++++------- .../daml/Splice/SvOnboarding.daml | 2 + .../daml/Splice/SvWeight.daml | 35 -- 5 files changed, 215 insertions(+), 177 deletions(-) delete mode 100644 daml/splice-dso-governance/daml/Splice/SvWeight.daml diff --git a/daml/splice-dso-governance/daml/Splice/DSO/SvState.daml b/daml/splice-dso-governance/daml/Splice/DSO/SvState.daml index 72239fd1f3..4523c77e94 100644 --- a/daml/splice-dso-governance/daml/Splice/DSO/SvState.daml +++ b/daml/splice-dso-governance/daml/Splice/DSO/SvState.daml @@ -116,6 +116,9 @@ template SvStatusReport with instance HasCheckedFetch SvRewardState ForSv where contractGroupId SvRewardState {..} = ForSv with dso; svName +instance HasCheckedFetch SvRewardState ForDso where + contractGroupId SvRewardState {..} = ForDso with dso + instance HasCheckedFetch SvNodeState ForSvNode where contractGroupId SvNodeState {..} = ForSvNode with dso; sv; svName diff --git a/daml/splice-dso-governance/daml/Splice/DsoBootstrap.daml b/daml/splice-dso-governance/daml/Splice/DsoBootstrap.daml index c3dedfbc78..d37bb95b26 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoBootstrap.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoBootstrap.daml @@ -74,6 +74,7 @@ template DsoBootstrap with config initialTrafficState isDevNet + hostedSvs = None create dsoRules -- create initial per-sv and per-operator contracts for sv1 let addSvChoiceArgs = DsoRules_AddSv with diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index f00189fed2..799c8abea5 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -5,7 +5,7 @@ module Splice.DsoRules where import Prelude hiding (all) -import DA.Action (void, unless) +import DA.Action (foldlA, void, unless) import DA.Assert import DA.Either (partitionEithers) import DA.Foldable (forA_, all) @@ -33,7 +33,6 @@ import Splice.SvOnboarding import Splice.DSO.AmuletPrice import Splice.DSO.DecentralizedSynchronizer import Splice.DSO.SvState -import Splice.SvWeight import Splice.Schedule import Splice.Util import Splice.CometBft() @@ -116,12 +115,14 @@ data DsoRules_ActionRequiringConfirmation -- ^ Voted action to create an UnallocatedUnclaimedActivityRecord contract. | SRARC_CreateBootstrapExternalPartyConfigStateInstruction DsoRules_CreateBootstrapExternalPartyConfigStateInstruction -- ^ Create BootstrapExternalPartyConfigStateInstruction - | SRARC_AddSvRewardWeight DsoRules_AddSvRewardWeight - -- ^ Voted action to add the weight of an SV. + | SRARC_AddHostedSv DsoRules_AddHostedSv + -- ^ Voted action to directly add a hosted SV. | SRARC_UpdateSvRewardWeight_V2 DsoRules_UpdateSvRewardWeight_V2 - -- ^ Voted action to update the weight of an SV. - | SRARC_RemoveSvRewardWeight DsoRules_RemoveSvRewardWeight - -- ^ Voted action to remove the weight of an SV. + -- ^ Voted action to update the weight of a hosted SV. + | SRARC_RemoveHostedSv DsoRules_RemoveHostedSv + -- ^ Voted action to remove a hosted SV. + | SRARC_AddSvNode DsoRules_AddSvNode + -- ^ Voted action to directly add an SV node. deriving (Eq, Show) data AnsEntryContext_ActionRequiringConfirmation @@ -133,10 +134,19 @@ data AnsEntryContext_ActionRequiringConfirmation -- | Information about SVs relevant to DSO governance. data SvInfo = SvInfo with + name : Text -- ^ Human-readable name; must be unique. + joinedAsOfRound : Round -- ^ __Deprecated__ in favor of `HostedSvInfo`. + svRewardWeight : Int -- ^ __Deprecated__ in favor of `HostedSvInfo`. + participantId : Text -- ^ Participant ID of the SV, stored here as PartyToParticipant mappings are tracked via state on the DsoRules + SvOnboardingConfirmed contracts. + deriving (Eq, Show) + +-- | Information about SVs relevant to DSO governance. +data HostedSvInfo = HostedSvInfo with name : Text -- ^ Human-readable name; must be unique. joinedAsOfRound : Round -- ^ Round in which the SV joined - svRewardWeight : Int -- ^ __Deprecated__ in favor of `SvWeight`. + svRewardWeight : Int -- ^ Weight of the SV in the SV reward distribution. participantId : Text -- ^ Participant ID of the SV, stored here as PartyToParticipant mappings are tracked via state on the DsoRules + SvOnboardingConfirmed contracts. + svOperator : Party -- ^ The SV operator party responsible for creating reward coupons for the SV. deriving (Eq, Show) -- | Information about offboarded svs @@ -153,6 +163,14 @@ data DsoSummary = DsoSummary with -- ^ The number of votes required for considering a confirmation, or a request for a vote deriving (Eq, Show) +data SvMigrationData = SvMigrationData with + svParty : Party -- ^ The SV party. + svName : Text -- ^ Human-readable name; must be unique. + svParticipantId : Text -- ^ Participant ID of the SV. + svRewardWeight : Int -- ^ Weight of the SV in the SV reward distribution. + deriving (Eq, Show) + + -- | Choice return types ------------------------- -- In order to support upgrades of the Daml models, all choices should return records, which can @@ -166,6 +184,10 @@ data DsoRules_AddSvResult = DsoRules_AddSvResult with data DsoRules_AddSvOperatorResult = DsoRules_AddSvOperatorResult with newDsoRules : ContractId DsoRules +data DsoRules_AddHostedSvResult = DsoRules_AddHostedSvResult with + svRewardStateCid : ContractId SvRewardState + newDsoRules : ContractId DsoRules + data DsoRules_OffboardSvResult = DsoRules_OffboardSvResult with newDsoRules : ContractId DsoRules @@ -211,15 +233,12 @@ data DsoRules_UpdateSvRewardWeightResult = DsoRules_UpdateSvRewardWeightResult w newDsoRules : ContractId DsoRules data DsoRules_UpdateSvRewardWeight_V2Result = DsoRules_UpdateSvRewardWeight_V2Result with - svWeightCid : ContractId SvWeight - -data DsoRules_AddSvRewardWeightResult = DsoRules_AddSvRewardWeightResult with - svWeightCid : ContractId SvWeight + newDsoRules : ContractId DsoRules -data DsoRules_RemoveSvRewardWeightResult = DsoRules_RemoveSvRewardWeightResult +data DsoRules_RemoveHostedSvResult = DsoRules_RemoveHostedSvResult with + newDsoRules : ContractId DsoRules -data DsoRules_MigrateSvWeightsResult = DsoRules_MigrateSvWeightsResult with - svWeightCids : [ContractId SvWeight] +data DsoRules_MigrateHostedSvsResult = DsoRules_MigrateHostedSvsResult with newDsoRules : ContractId DsoRules data DsoRules_GrantFeaturedAppRightResult = DsoRules_GrantFeaturedAppRightResult with @@ -286,6 +305,9 @@ data DsoRules_ReceiveSvRewardCouponResult = DsoRules_ReceiveSvRewardCouponResult svRewardState : ContractId SvRewardState svRewardCoupons : [ContractId SvRewardCoupon] +data DsoRules_ReceiveSvRewardCoupon_V2Result = DsoRules_ReceiveSvRewardCoupon_V2Result with + svRewardStatesWithSvRewardCoupons : [(ContractId SvRewardState, [ContractId SvRewardCoupon])] + data DsoRules_ClaimExpiredRewardsResult = DsoRules_ClaimExpiredRewardsResult with unclaimedReward: Optional (ContractId UnclaimedReward) @@ -514,13 +536,14 @@ data TrafficState = TrafficState with template DsoRules with dso : Party epoch : Int - svs : Map.Map Party SvInfo + svs : Map.Map Party SvInfo -- ^ List of SV nodes. offboardedSvs : Map.Map Party OffboardedSvInfo dsoDelegate : Party -- ^ __Deprecated__ in favor of delegateless automation. config : DsoRulesConfig initialTrafficState: Map.Map Text TrafficState -- ^ Map from participant/mediator ID to its traffic state at the time of synchronizer bootstrapping. Used for testing, empty in prod. isDevNet : Bool + hostedSvs : Optional (Map.Map Party HostedSvInfo) -- ^ List of hosted SVs. where ensure config.numUnclaimedRewardsThreshold > 0 @@ -547,17 +570,13 @@ template DsoRules with do newDsoRules <- dsoRules_addSv this arg return DsoRules_AddSvResult with .. - choice DsoRules_AddSvOperator : DsoRules_AddSvOperatorResult + choice DsoRules_AddSvNode : DsoRules_AddSvOperatorResult with - newSvParty : Party - -- DRAFT note for the reviewer: In the future, newSvName should not be used, since an SV operator may operate multiple SVs. - -- Removing newSvName in this proposal would require modifying the voting flows to accept votes for all SVs (SVs operating a node and hosted SVs), - -- which is not a requirement in the current proposal. - newSvName : Text - newSvParticipantId : Text - joinedAsOfRound : Round + newSvOperatorParty : Party + newSvNodeName : Text + newSvNodeParticipantId : Text controller dso - do newDsoRules <- dsoRules_AddSvOperator this arg + do newDsoRules <- dsoRules_AddSvNode this arg return DsoRules_AddSvOperatorResult with .. choice DsoRules_OffboardSv : DsoRules_OffboardSvResult @@ -590,9 +609,46 @@ template DsoRules with config initialTrafficState isDevNet + hostedSvs return DsoRules_OffboardSvResult with .. + choice DsoRules_AddHostedSv : DsoRules_AddHostedSvResult + with + newSvParty : Party + newSvName : Text + newSvRewardWeight : Int + newSvParticipantId : Text + joinedAsOfRound : Round + svOperator : Party + controller dso + do + -- Sanity checks + let isSvOperator = flip Map.member svs + require "svOperator is an SV operator" $ isSvOperator svOperator + + svRewardStateCid <- create SvRewardState with + dso + svName = newSvName + state = RewardState with + lastRoundCollected = Round (joinedAsOfRound.number - 1) + numRoundsMissed = 0 + numRoundsCollected = 0 + numCouponsIssued = 0 + + -- register the new SV in the DsoRules + let hostedSvInfo = HostedSvInfo with + name = newSvName + joinedAsOfRound + svRewardWeight = newSvRewardWeight + participantId = newSvParticipantId + svOperator + updatedHostedSvs = Map.insert newSvParty hostedSvInfo (fromOptional Map.empty hostedSvs) + + newDsoRules <- create this with hostedSvs = Some updatedHostedSvs + + pure DsoRules_AddHostedSvResult with newDsoRules; svRewardStateCid + -- Update an SV's Status report nonconsuming choice DsoRules_SubmitStatusReport : DsoRules_SubmitStatusReportResult with @@ -613,6 +669,7 @@ template DsoRules with status = Some status return DsoRules_SubmitStatusReportResult with .. + -- TODO: Deprecate -- Called by SV candidates to add themselves to the DsoRules once they are confirmed and ready. -- We use the passed rounds for reliably determining the earliest open round, which will be -- the first round in which the new SV will receive rewards. @@ -1003,7 +1060,7 @@ template DsoRules with -- SV weight management ----------------------- - -- TODO: Deprecate once all SVs have migrated to SvWeight contracts. + -- TODO: Deprecate once all SVs have migrated to `hostedSvs`. choice DsoRules_UpdateSvRewardWeight : DsoRules_UpdateSvRewardWeightResult with svParty : Party @@ -1021,72 +1078,78 @@ template DsoRules with choice DsoRules_UpdateSvRewardWeight_V2 : DsoRules_UpdateSvRewardWeight_V2Result with - svWeightCid : ContractId SvWeight + svParty : Party newRewardWeight : Int controller dso do - svWeight <- fetchAndArchive (ForDso with dso) svWeightCid - svWeightCid <- create svWeight with - weight = newRewardWeight - pure DsoRules_UpdateSvRewardWeight_V2Result with svWeightCid - - nonconsuming choice DsoRules_AddSvRewardWeight : DsoRules_AddSvRewardWeightResult - with - svOperator : Party - sv : Party - rewardWeight : Int - controller dso - do - -- Sanity checks - let isSvOperator = flip Map.member svs - require "svOperator is an SV operator" $ isSvOperator svOperator - - svWeightCid <- create SvWeight with dso; sv; svOperator; weight = rewardWeight - pure DsoRules_AddSvRewardWeightResult with svWeightCid + require "New reward weight is positive" (newRewardWeight > 0) + let hostedSvsMap = fromOptional Map.empty hostedSvs + case Map.lookup svParty hostedSvsMap of + None -> fail "SV party is not registered" + Some hostedSv -> do + let newHostedSv = hostedSv with svRewardWeight = newRewardWeight + newHostedSvs = Map.insert svParty newHostedSv hostedSvsMap + newDsoRules <- create this with hostedSvs = Some newHostedSvs + pure DsoRules_UpdateSvRewardWeight_V2Result with newDsoRules - nonconsuming choice DsoRules_RemoveSvRewardWeight : DsoRules_RemoveSvRewardWeightResult + choice DsoRules_RemoveHostedSv : DsoRules_RemoveHostedSvResult with - svWeightCid : ContractId SvWeight + svParty : Party controller dso do - _ <- fetchAndArchive (ForDso with dso) svWeightCid - pure DsoRules_RemoveSvRewardWeightResult + let hostedSvsMap = fromOptional Map.empty hostedSvs + case Map.lookup svParty hostedSvsMap of + None -> fail "SV party is not registered" + Some _ -> do + let newHostedSvs = Map.delete svParty hostedSvsMap + newDsoRules <- create this with hostedSvs = Some newHostedSvs + pure DsoRules_RemoveHostedSvResult with newDsoRules - -- TODO: Deprecate once all SVs have migrated to SvWeight contracts. + -- TODO: Deprecate once all SVs have migrated to `hostedSvs`. -- DRAFT note for the reviewer: Please, check migration procedure in the PR description. - choice DsoRules_MigrateSvWeights : DsoRules_MigrateSvWeightsResult + choice DsoRules_MigrateHostedSvs : DsoRules_MigrateHostedSvsResult with -- Complete on-ledger reward weight distribution for the SVs operated by this SV operator. -- The sum of these weights must match the SV operator's current legacy reward weight. - -- Extra beneficiaries used only for internal reward sharing must not be migrated as SvWeights; + -- Extra beneficiaries used only for internal reward sharing must not be migrated as weights on-ledger; -- their share should be included in the corresponding SV's weight and configured separately -- as beneficiary splits in the SV app. - svWeights : [(Party, Int)] + svsMigrationData : [SvMigrationData] + openRoundCid : ContractId OpenMiningRound sv : Party controller sv do case Map.lookup sv svs of - None -> fail "Not a sv" + None -> fail "SV is not an SV operator" Some svInfo -> do -- Sanity checks - let (svParties, rewardWeights) = unzip svWeights + let (svParties, rewardWeights) = unzip $ (\d -> (d.svParty, d.svRewardWeight)) <$> svsMigrationData require "Total SV weights match SV operator weight" (sum rewardWeights == svInfo.svRewardWeight) require "SVs are unique" (unique svParties) + -- check round + now <- getTime + openRound <- fetchReferenceData (ForDso with dso) openRoundCid + require "OpenRound is open" (openRound.opensAt <= now) + -- Invalidate the SV operator's legacy reward weight. let newSv = svInfo with svRewardWeight = 0 newSvs = Map.insert sv newSv svs - newDsoRules <- create this with svs = newSvs + updatedDsoRulesWithLegacyWeightInvalidated <- create this with svs = newSvs - -- Create hosted SVs weigths - svWeightCids <- forA svWeights $ \(svParty, rewardWeight) -> - create SvWeight with - dso - sv = svParty - svOperator = sv - weight = rewardWeight + -- Add hosted SVs + let addHostedSv self' svMigrationData = do + DsoRules_AddHostedSvResult {newDsoRules} <- exercise self' DsoRules_AddHostedSv with + newSvParty = svMigrationData.svParty + newSvName = svMigrationData.svName + newSvRewardWeight = svMigrationData.svRewardWeight + newSvParticipantId = svMigrationData.svParticipantId + joinedAsOfRound = openRound.round + svOperator = sv + pure newDsoRules + newDsoRules <- foldlA addHostedSv updatedDsoRulesWithLegacyWeightInvalidated svsMigrationData - pure DsoRules_MigrateSvWeightsResult with svWeightCids; newDsoRules + pure DsoRules_MigrateHostedSvsResult with newDsoRules -- App rights management @@ -1160,6 +1223,7 @@ template DsoRules with -- SV onboarding ---------------- + -- TODO: Deprecate nonconsuming choice DsoRules_StartSvOnboarding : DsoRules_StartSvOnboardingResult with candidateName : Text @@ -1175,6 +1239,7 @@ template DsoRules with onboardingRequest <- create SvOnboardingRequest with .. return DsoRules_StartSvOnboardingResult with .. + -- TODO: Deprecate nonconsuming choice DsoRules_ExpireSvOnboardingRequest : DsoRules_ExpireSvOnboardingRequestResult with cid: ContractId SvOnboardingRequest @@ -1185,6 +1250,7 @@ template DsoRules with exercise cid SvOnboardingRequest_Expire return DsoRules_ExpireSvOnboardingRequestResult + -- TODO: Deprecate nonconsuming choice DsoRules_ArchiveSvOnboardingRequest : DsoRules_ArchiveSvOnboardingRequestResult with svOnboardingRequestCid: ContractId SvOnboardingRequest @@ -1199,6 +1265,7 @@ template DsoRules with require "SV name matches" ((fromSome maybeSv).name == svOnboardingRequest.candidateName) return DsoRules_ArchiveSvOnboardingRequestResult + -- TODO: Deprecate nonconsuming choice DsoRules_ConfirmSvOnboarding : DsoRules_ConfirmSvOnboardingResult with newSvParty : Party @@ -1225,6 +1292,7 @@ template DsoRules with expiresAt return DsoRules_ConfirmSvOnboardingResult with .. + -- TODO: Deprecate choice DsoRules_ExpireSvOnboardingConfirmed : DsoRules_ExpireSvOnboardingConfirmedResult with cid: ContractId SvOnboardingConfirmed @@ -1409,7 +1477,7 @@ template DsoRules with -- Reward management driven directly by the DSO delegates --------------------------------------------------------- - -- TODO: Deprecate once all SVs have migrated to SvWeight contracts. + -- TODO: Deprecate once all SVs have migrated to `hostedSvs`. nonconsuming choice DsoRules_ReceiveSvRewardCoupon : DsoRules_ReceiveSvRewardCouponResult with sv : Party @@ -1458,66 +1526,71 @@ template DsoRules with svRewardState = newRewardStateCid svRewardCoupons = couponCids - nonconsuming choice DsoRules_ReceiveSvRewardCoupon_V2 : DsoRules_ReceiveSvRewardCouponResult + nonconsuming choice DsoRules_ReceiveSvRewardCoupon_V2 : DsoRules_ReceiveSvRewardCoupon_V2Result with sv : Party openRoundCid : ContractId OpenMiningRound - rewardStateCid : ContractId SvRewardState - -- All SvWeights for SVs operated by this SV operator, each with an optional beneficiary split. - -- If the beneficiary list is empty, the SvWeight's SV receives one coupon with the full weight. - -- Otherwise, one coupon is issued per listed beneficiary, and the listed weights must sum to the SvWeight weight. - svWeights : [(ContractId SvWeight, [(Party, Int)])] + -- SvRewardStates to collect rewards for in this call, each with an optional beneficiary split. + -- Each SvRewardState must correspond to an SV operated by this SV operator. + -- If the beneficiary list is empty, the corresponding SV receives one coupon with the full weight. + -- Otherwise, one coupon is issued per listed beneficiary, and the listed weights must sum to the SV's reward weight. + rewardStatesWithBeneficiaries : [(ContractId SvRewardState, [(Party, Int)])] controller sv do case Map.lookup sv svs of - None -> fail "SV is not an SV" - Some info -> do + None -> fail "SV is not an SV operator" + Some _ -> do -- check round now <- getTime openRound <- fetchReferenceData (ForDso with dso) openRoundCid require "OpenRound is open" (openRound.opensAt <= now) - beneficiariesPerSvWithSv <- forA svWeights $ \(svWeightCid, beneficiaries) -> do - svWeight <- fetchChecked (ForOwner with dso; owner = sv) svWeightCid - let defaultBeneficiaries = [(svWeight.sv, svWeight.weight)] - case beneficiaries of - [] -> pure (defaultBeneficiaries, svWeight.sv) - _ -> do - require "Total beneficiary weight matches SV weight" $ sum (map snd beneficiaries) == svWeight.weight - require "At most one coupon per beneficiary" (unique $ map fst beneficiaries) - pure (beneficiaries, svWeight.sv) - - let - (beneficiariesPerSv, svs) = unzip beneficiariesPerSvWithSv - allBeneficiaries = concat beneficiariesPerSv - require "Each SV weight can be used at most once" (unique svs) - - -- check and update state - rewardState <- fetchAndArchive (ForSv with dso; svName = info.name) rewardStateCid - let state = rewardState.state - require - ("Round " <> show openRound.round <> " is greater than the last round a reward has been received for " <> show state.lastRoundCollected) - (state.lastRoundCollected < openRound.round) - - newRewardStateCid <- create rewardState with - state = RewardState with - lastRoundCollected = openRound.round - numRoundsCollected = state.numRoundsCollected + 1 - numRoundsMissed = - state.numRoundsMissed + (openRound.round.number - state.lastRoundCollected.number - 1) - numCouponsIssued = state.numCouponsIssued + length allBeneficiaries - - couponCids <- forA allBeneficiaries $ \(beneficiary, weight) -> - create SvRewardCoupon with - dso - sv - beneficiary - weight - round = openRound.round - - return DsoRules_ReceiveSvRewardCouponResult with - svRewardState = newRewardStateCid - svRewardCoupons = couponCids + require "rewardStates are unique" (unique $ map fst rewardStatesWithBeneficiaries) + + svRewardStatesWithSvRewardCoupons <- + forA rewardStatesWithBeneficiaries $ \(rewardStateCid, beneficiaries) -> do + -- check and update state + rewardState <- fetchAndArchive (ForDso with dso) rewardStateCid + let state = rewardState.state + require + ("Round " <> show openRound.round <> " is greater than the last round a reward has been received for " <> show state.lastRoundCollected) + (state.lastRoundCollected < openRound.round) + + case lookupHostedSvInfoByName rewardState.svName this of + None -> fail $ show rewardState.svName <> " is not onboarded" + Some (hostedSv, hostedSvInfo) -> do + -- check SV operator + require + ("Only SV operator " <> show hostedSvInfo.svOperator <> " can create SV rewards for SV " <> show hostedSv) + (hostedSvInfo.svOperator == sv) + + let svRewardWeight = hostedSvInfo.svRewardWeight + beneficiaries' <- case beneficiaries of + [] -> pure [(hostedSv, svRewardWeight)] + _ -> do + require "Total beneficiary weight matches SV weight" $ sum (map snd beneficiaries) == svRewardWeight + require "At most one coupon per beneficiary" (unique $ map fst beneficiaries) + pure beneficiaries + + newRewardStateCid <- create rewardState with + state = RewardState with + lastRoundCollected = openRound.round + numRoundsCollected = state.numRoundsCollected + 1 + numRoundsMissed = + state.numRoundsMissed + (openRound.round.number - state.lastRoundCollected.number - 1) + numCouponsIssued = state.numCouponsIssued + length beneficiaries' + + couponCids <- forA beneficiaries' $ \(beneficiary, weight) -> + create SvRewardCoupon with + dso + sv = hostedSv + beneficiary + weight + round = openRound.round + + pure (newRewardStateCid, couponCids) + + pure DsoRules_ReceiveSvRewardCoupon_V2Result with svRewardStatesWithSvRewardCoupons -- Batch expiry of unclaimed rewards for a specific claimed round @@ -1886,9 +1959,10 @@ executeActionRequiringConfirmation dso dsoRulesCid amuletRulesCid act = case act SRARC_CreateTransferCommandCounter choiceArg -> void $ exercise dsoRulesCid choiceArg SRARC_CreateUnallocatedUnclaimedActivityRecord choiceArg -> void $ exercise dsoRulesCid choiceArg SRARC_CreateBootstrapExternalPartyConfigStateInstruction choiceArg -> void $ exercise dsoRulesCid choiceArg - SRARC_AddSvRewardWeight choiceArg -> void $ exercise dsoRulesCid choiceArg + SRARC_AddHostedSv choiceArg -> void $ exercise dsoRulesCid choiceArg SRARC_UpdateSvRewardWeight_V2 choiceArg -> void $ exercise dsoRulesCid choiceArg - SRARC_RemoveSvRewardWeight choiceArg -> void $ exercise dsoRulesCid choiceArg + SRARC_RemoveHostedSv choiceArg -> void $ exercise dsoRulesCid choiceArg + SRARC_AddSvNode choiceArg -> void $ exercise dsoRulesCid choiceArg ARC_AnsEntryContext with .. -> do void $ fetchChecked (ForDso with dso) ansEntryContextCid case ansEntryContextAction of @@ -1959,6 +2033,12 @@ lookupSvInfoByName : Text -> DsoRules -> Optional (Party, SvInfo) lookupSvInfoByName svName DsoRules{..} = find (\info -> info._2.name == svName) $ Map.toList svs +lookupHostedSvInfoByName : Text -> DsoRules -> Optional (Party, HostedSvInfo) +lookupHostedSvInfoByName svName DsoRules{..} = + case hostedSvs of + None -> None + Some hostedSvsMap -> find (\(_, info) -> info.name == svName) $ Map.toList hostedSvsMap + -- | Returns True if an SV with that name is either currently onboarded -- or an SV with that name has been onboarded before and is now in offboardedSvs. svHasBeenOnboardedBefore : Text -> DsoRules -> Bool @@ -1971,15 +2051,15 @@ ensureNeverOperatedNode newSvParty this = do not (newSvParty `Map.member` this.svs || newSvParty `Map.member` this.offboardedSvs) -- factored out from the choice to avoid mistakes from having the fields of DsoRules{..} in scope -dsoRules_AddSvOperator : DsoRules -> DsoRules_AddSvOperator -> Update (ContractId DsoRules) -dsoRules_AddSvOperator this0 arg@DsoRules_AddSvOperator{..} = do - ensureNeverOperatedNode newSvParty this0 +dsoRules_AddSvNode : DsoRules -> DsoRules_AddSvNode -> Update (ContractId DsoRules) +dsoRules_AddSvNode this0 DsoRules_AddSvNode{..} = do + ensureNeverOperatedNode newSvOperatorParty this0 -- in DevNet we allow changing the operator of an existing sv, which we implement as a removal and re-addition this@DsoRules{..} <- if this0.isDevNet then let DsoRules{..} = this0 in -- bring all DsoRules fields into scope - case lookupSvInfoByName newSvName this0 of + case lookupSvInfoByName newSvNodeName this0 of None -> pure this0 Some (sv, info) -> do let offboardingInfo = OffboardedSvInfo with @@ -1989,24 +2069,22 @@ dsoRules_AddSvOperator this0 arg@DsoRules_AddSvOperator{..} = do svs = Map.delete sv svs offboardedSvs = Map.insert sv offboardingInfo offboardedSvs else do - require "SV is not currently onboarded" (isNone $ lookupSvInfoByName newSvName this0) + require "SV is not currently onboarded" (isNone $ lookupSvInfoByName newSvNodeName this0) pure this0 - -- create per-sv contracts if they have never been onboarded - unless (svHasBeenOnboardedBefore newSvName this) $ - createPerSvContracts' this arg - -- create per SV party contracts + -- create per SV operator party contracts let initialAmuletPriceVote = None - createPerSvPartyContracts dso newSvParty newSvName noSynchronizerNodes initialAmuletPriceVote (getVoteCooldownTime config) + createPerSvPartyContracts dso newSvOperatorParty newSvNodeName noSynchronizerNodes initialAmuletPriceVote (getVoteCooldownTime config) -- register the new operator in the DsoRules let svInfo = SvInfo with - name = newSvName - joinedAsOfRound - -- Set to zero, as the SV weights are represented as `SvWeight` contracts + name = newSvNodeName + -- Set to zero because it is no longer relevant for SV operators + joinedAsOfRound = Round 0 + -- Set to zero, as the SV weights are maintained in `hostedSvs` svRewardWeight = 0 - participantId = newSvParticipantId + participantId = newSvNodeParticipantId create this with - svs = Map.insert newSvParty svInfo svs + svs = Map.insert newSvOperatorParty svInfo svs -- factored out from the choice to avoid mistakes from having the fields of DsoRules{..} in scope dsoRules_addSv : DsoRules -> DsoRules_AddSv -> Update (ContractId DsoRules) @@ -2045,17 +2123,6 @@ dsoRules_addSv this0 arg@DsoRules_AddSv{..} = do create this with svs = Map.insert newSvParty svInfo svs -createPerSvContracts' : DsoRules -> DsoRules_AddSvOperator -> Update () -createPerSvContracts' DsoRules{..} DsoRules_AddSvOperator{..} = do - void $ create SvRewardState with - dso - svName = newSvName - state = RewardState with - lastRoundCollected = Round (joinedAsOfRound.number - 1) - numRoundsMissed = 0 - numRoundsCollected = 0 - numCouponsIssued = 0 - createPerSvPartyContracts : Party -> Party -> Text -> SynchronizerNodeConfigMap -> Optional Decimal -> RelTime -> Update () createPerSvPartyContracts dso newSvParty newSvName synchronizerNodes amuletPrice voteCooldownTime = do -- Note: we currently track the amulet-price vote on a per operator basis. diff --git a/daml/splice-dso-governance/daml/Splice/SvOnboarding.daml b/daml/splice-dso-governance/daml/Splice/SvOnboarding.daml index 6584666a4c..f5acb93f95 100644 --- a/daml/splice-dso-governance/daml/Splice/SvOnboarding.daml +++ b/daml/splice-dso-governance/daml/Splice/SvOnboarding.daml @@ -11,6 +11,7 @@ data SvOnboardingRequest_ExpireResult = SvOnboardingRequest_ExpireResult data SvOnboardingConfirmed_ExpireResult = SvOnboardingConfirmed_ExpireResult +-- TODO: Deprecate -- | Template used by the SVs to collect confirmations for the onboarding of an SV candidate. -- The existence of this contract triggers SV automation that creates confirmation contracts for -- the candidate if the token is a valid `SvOnboardingToken` and matches an `ApprovedSvIdentity`. @@ -34,6 +35,7 @@ template SvOnboardingRequest with require "Onboarding has expired" (now >= expiresAt) pure SvOnboardingRequest_ExpireResult +-- TODO: Deprecate -- | A confirmation for approval of a candidate SV. -- -- Once this contract is created, the workflows to onboard that SVs node starts. diff --git a/daml/splice-dso-governance/daml/Splice/SvWeight.daml b/daml/splice-dso-governance/daml/Splice/SvWeight.daml deleted file mode 100644 index fd1a5d7e79..0000000000 --- a/daml/splice-dso-governance/daml/Splice/SvWeight.daml +++ /dev/null @@ -1,35 +0,0 @@ --- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. --- SPDX-License-Identifier: Apache-2.0 - -module Splice.SvWeight where - -import Splice.Types -import Splice.Util - - --- | On-ledger representation of the SV weight. --- --- A 'SvWeight' links a SV party to a SV operator that records --- the reward weight used to create SV reward coupons for the SV. -template SvWeight - with - dso : Party - sv : Party -- ^ The SV party for which reward coupons are created by the SV operator. - svOperator : Party -- ^ The SV operator party responsible for creating reward coupons for 'sv'. - weight : Int -- ^ The SV reward weight. Must be strictly greater than 0. - where - ensure weight > 0 - signatory dso, svOperator - - observer sv - - - --- instances ------------- - -instance HasCheckedFetch SvWeight ForOwner where - contractGroupId SvWeight{..} = ForOwner with dso; owner = sv - -instance HasCheckedFetch SvWeight ForDso where - contractGroupId SvWeight{..} = ForDso with dso \ No newline at end of file From 1c41f9837a457608849b380c64d8e6f07fd72bec Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Tue, 19 May 2026 16:06:00 +0000 Subject: [PATCH 04/19] leftovers Signed-off-by: Jose Velasco --- .../daml/Splice/DsoRules.daml | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 799c8abea5..1ddc9e4cee 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -566,9 +566,8 @@ template DsoRules with newSvParticipantId : Text joinedAsOfRound : Round controller dso - do - do newDsoRules <- dsoRules_addSv this arg - return DsoRules_AddSvResult with .. + do newDsoRules <- dsoRules_addSv this arg + return DsoRules_AddSvResult with .. choice DsoRules_AddSvNode : DsoRules_AddSvOperatorResult with @@ -2123,6 +2122,17 @@ dsoRules_addSv this0 arg@DsoRules_AddSv{..} = do create this with svs = Map.insert newSvParty svInfo svs +createPerSvContracts : DsoRules -> DsoRules_AddSv -> Update () +createPerSvContracts DsoRules{..} DsoRules_AddSv{..} = do + void $ create SvRewardState with + dso + svName = newSvName + state = RewardState with + lastRoundCollected = Round (joinedAsOfRound.number - 1) + numRoundsMissed = 0 + numRoundsCollected = 0 + numCouponsIssued = 0 + createPerSvPartyContracts : Party -> Party -> Text -> SynchronizerNodeConfigMap -> Optional Decimal -> RelTime -> Update () createPerSvPartyContracts dso newSvParty newSvName synchronizerNodes amuletPrice voteCooldownTime = do -- Note: we currently track the amulet-price vote on a per operator basis. @@ -2148,17 +2158,6 @@ createPerSvPartyContracts dso newSvParty newSvName synchronizerNodes amuletPrice state = NodeState with synchronizerNodes -createPerSvContracts : DsoRules -> DsoRules_AddSv -> Update () -createPerSvContracts DsoRules{..} DsoRules_AddSv{..} = do - void $ create SvRewardState with - dso - svName = newSvName - state = RewardState with - lastRoundCollected = Round (joinedAsOfRound.number - 1) - numRoundsMissed = 0 - numRoundsCollected = 0 - numCouponsIssued = 0 - getAndValidateSvParty : DsoRules -> Optional Party -> Update Party getAndValidateSvParty _ None = fail "no SV party provided" getAndValidateSvParty rules (Some sv) = do From 3bea7dc87e83b51be3b448ca4c083f785adc6259 Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Wed, 20 May 2026 16:15:38 +0000 Subject: [PATCH 05/19] move extra beneficiaries on-ledger Signed-off-by: Jose Velasco --- .../daml/Splice/DsoRules.daml | 244 ++++++++++++++++-- 1 file changed, 221 insertions(+), 23 deletions(-) diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 1ddc9e4cee..3cf5177f4d 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -238,6 +238,9 @@ data DsoRules_UpdateSvRewardWeight_V2Result = DsoRules_UpdateSvRewardWeight_V2Re data DsoRules_RemoveHostedSvResult = DsoRules_RemoveHostedSvResult with newDsoRules : ContractId DsoRules +data DsoRules_SetSvRewardBeneficiariesResult = DsoRules_SetSvRewardBeneficiariesResult with + svRewardBeneficiariesCid : Optional (ContractId SvRewardBeneficiaries) + data DsoRules_MigrateHostedSvsResult = DsoRules_MigrateHostedSvsResult with newDsoRules : ContractId DsoRules @@ -533,6 +536,59 @@ data TrafficState = TrafficState with consumedTraffic: Int -- ^ Bytes of extra traffic consumed before the decentralized synchronizer was bootstrapped. deriving (Eq, Show) + +-- | Optional on-ledger representation of the SV reward beneficiary distribution for an SV. +-- +-- When this contract exists for an SV, part of the SV reward coupons for that +-- SV may be distributed across the configured beneficiaries according to their +-- weights. The total beneficiary weight must be less than or equal to the SV +-- weight assigned in 'DsoRules'. +-- +-- The SV itself must not be listed as a beneficiary in this contract. Any +-- remaining SV reward weight not assigned to beneficiaries is created for the +-- SV itself. +-- +-- When this contract does not exist, all SV reward coupons for the SV are +-- created for the SV itself. +template SvRewardBeneficiaries + with + dso : Party + sv : Party + -- ^ The SV party whose reward coupons may be partially distributed to beneficiaries. + svOperator : Party + -- ^ The SV operator party responsible for creating reward coupons for 'sv'. + beneficiaries : [(Party, Int)] + -- ^ Beneficiary parties, excluding 'sv', and their corresponding positive + -- reward weights. Each beneficiary party must be unique. + where + ensure + not (null beneficiaries) + && unique (map fst beneficiaries) + && all (> 0) (map snd beneficiaries) + && notElem sv (map fst beneficiaries) + + signatory dso, sv + observer svOperator + +-- | Action to apply to the SV reward beneficiary distribution. +data SetSvRewardBeneficiariesAction + = SetBeneficiaries with + optCid : Optional (ContractId SvRewardBeneficiaries) + -- ^ The existing beneficiary distribution contract, if one exists. + -- 'None' creates a new distribution. 'Some' archives and replaces the + -- existing distribution. + beneficiaries : [(Party, Int)] + -- ^ The full beneficiary distribution to set. The total beneficiary + -- weight must be less than or equal to the SV weight in 'DsoRules'. + -- Any unassigned SV weight is created for the SV itself. + | ClearBeneficiaries with + cid : ContractId SvRewardBeneficiaries + -- ^ The existing beneficiary distribution contract to archive. + -- After clearing, all SV reward coupons for the SV are created for + -- the SV itself. + deriving (Eq, Show) + + template DsoRules with dso : Party epoch : Int @@ -1104,18 +1160,69 @@ template DsoRules with newDsoRules <- create this with hostedSvs = Some newHostedSvs pure DsoRules_RemoveHostedSvResult with newDsoRules + + -- | Sets or clears the SV reward beneficiary distribution for an SV. + -- + -- This choice is optional for SVs that want to share their SV rewards with + -- beneficiaries. It creates, updates, or removes the corresponding + -- 'SvRewardBeneficiaries' contract. If no such contract exists, all SV reward + -- coupons for the SV are created for the SV itself. + nonconsuming choice DsoRules_SetSvRewardBeneficiaries : DsoRules_SetSvRewardBeneficiariesResult + with + action : SetSvRewardBeneficiariesAction + -- ^ The requested operation to apply. + svParty : Party + -- ^ The SV party whose beneficiary distribution is being managed. + controller svParty + do + svRewardBeneficiariesCid <- + case Map.lookup svParty (fromOptional Map.empty hostedSvs) of + None -> fail "SV party is not registered" + Some hostedSvInfo -> do + let svWeight = hostedSvInfo.svRewardWeight + assertWeights weights = + require ("Sum of weights is less than SV's weight " <> show svWeight) (sum weights <= svWeight) + case action of + ClearBeneficiaries cid -> do + _ <- fetchAndArchive (ForOwner with dso; owner = svParty) cid + pure None + + SetBeneficiaries {optCid = Some cid; beneficiaries} -> do + svRewardBeneficiaries <- fetchAndArchive (ForOwner with dso; owner = svParty) cid + assertWeights (map snd beneficiaries) + Some <$> create svRewardBeneficiaries with beneficiaries + + SetBeneficiaries {optCid = None; beneficiaries} -> do + assertWeights (map snd beneficiaries) + Some <$> create SvRewardBeneficiaries with + dso + sv = svParty + svOperator = hostedSvInfo.svOperator + beneficiaries + + pure DsoRules_SetSvRewardBeneficiariesResult with svRewardBeneficiariesCid + -- TODO: Deprecate once all SVs have migrated to `hostedSvs`. - -- DRAFT note for the reviewer: Please, check migration procedure in the PR description. + -- DRAFT note for the reviewer: Please check the migration procedure in the PR description. choice DsoRules_MigrateHostedSvs : DsoRules_MigrateHostedSvsResult with - -- Complete on-ledger reward weight distribution for the SVs operated by this SV operator. - -- The sum of these weights must match the SV operator's current legacy reward weight. - -- Extra beneficiaries used only for internal reward sharing must not be migrated as weights on-ledger; - -- their share should be included in the corresponding SV's weight and configured separately - -- as beneficiary splits in the SV app. + -- Complete on-ledger SV reward weight distribution for the SVs operated by + -- this SV operator. + -- + -- The sum of all migrated SV weights must match the SV operator's current + -- legacy reward weight. + -- + -- This migration only moves SV reward weights on-ledger. It does not create + -- 'SvRewardBeneficiaries' contracts. Any optional beneficiary distribution + -- must be configured separately by the corresponding SV after migration. + -- + -- Extra beneficiaries previously used only for internal reward sharing must + -- not be migrated as independent SV weights. Their share should instead + -- remain part of the corresponding SV's migrated weight and, if needed, be + -- configured later through 'SvRewardBeneficiaries'. svsMigrationData : [SvMigrationData] openRoundCid : ContractId OpenMiningRound - sv : Party + sv : Party -- ^ The SV operator performing the migration. controller sv do case Map.lookup sv svs of @@ -1529,11 +1636,16 @@ template DsoRules with with sv : Party openRoundCid : ContractId OpenMiningRound - -- SvRewardStates to collect rewards for in this call, each with an optional beneficiary split. + -- SvRewardStates to collect rewards for in this call, each with an optional + -- beneficiary distribution. + -- -- Each SvRewardState must correspond to an SV operated by this SV operator. - -- If the beneficiary list is empty, the corresponding SV receives one coupon with the full weight. - -- Otherwise, one coupon is issued per listed beneficiary, and the listed weights must sum to the SV's reward weight. - rewardStatesWithBeneficiaries : [(ContractId SvRewardState, [(Party, Int)])] + -- If no SvRewardBeneficiaries contract is provided, the corresponding SV + -- receives one coupon with the full SV weight. + -- If a SvRewardBeneficiaries contract is provided, one coupon is issued per + -- configured beneficiary. The total beneficiary weight must be less than or + -- equal to the SV weight. Any remaining weight is issued to the SV itself. + rewardStatesWithBeneficiaries : [(ContractId SvRewardState, Optional (ContractId SvRewardBeneficiaries))] controller sv do case Map.lookup sv svs of @@ -1544,10 +1656,10 @@ template DsoRules with openRound <- fetchReferenceData (ForDso with dso) openRoundCid require "OpenRound is open" (openRound.opensAt <= now) - require "rewardStates are unique" (unique $ map fst rewardStatesWithBeneficiaries) + require "RewardState contract ids are unique" (unique $ map fst rewardStatesWithBeneficiaries) svRewardStatesWithSvRewardCoupons <- - forA rewardStatesWithBeneficiaries $ \(rewardStateCid, beneficiaries) -> do + forA rewardStatesWithBeneficiaries $ \(rewardStateCid, optSvRewardBeneficiariesCid) -> do -- check and update state rewardState <- fetchAndArchive (ForDso with dso) rewardStateCid let state = rewardState.state @@ -1556,20 +1668,34 @@ template DsoRules with (state.lastRoundCollected < openRound.round) case lookupHostedSvInfoByName rewardState.svName this of - None -> fail $ show rewardState.svName <> " is not onboarded" + None -> fail $ "SV " <> show rewardState.svName <> " is not registered" Some (hostedSv, hostedSvInfo) -> do - -- check SV operator require ("Only SV operator " <> show hostedSvInfo.svOperator <> " can create SV rewards for SV " <> show hostedSv) (hostedSvInfo.svOperator == sv) let svRewardWeight = hostedSvInfo.svRewardWeight - beneficiaries' <- case beneficiaries of - [] -> pure [(hostedSv, svRewardWeight)] - _ -> do - require "Total beneficiary weight matches SV weight" $ sum (map snd beneficiaries) == svRewardWeight - require "At most one coupon per beneficiary" (unique $ map fst beneficiaries) - pure beneficiaries + beneficiaries <- case optSvRewardBeneficiariesCid of + None -> pure [(hostedSv, svRewardWeight)] + Some svRewardBeneficiariesCid -> do + svRewardBeneficiaries <- fetchChecked (ForDso with dso) svRewardBeneficiariesCid + let beneficiaries = svRewardBeneficiaries.beneficiaries + require + "SvRewardBeneficiaries contract belongs to the SV operator creating the rewards" + (svRewardBeneficiaries.svOperator == sv) + require + "SvRewardBeneficiaries contract belongs to the corresponding SV" + (svRewardBeneficiaries.sv == hostedSv) + + let assignedWeight = sum (map snd beneficiaries) + remainingWeight = svRewardWeight - assignedWeight + require + ("Sum of beneficiary weights " <> show assignedWeight <> " must be less than or equal to the SV weight " <> show svRewardWeight) + (assignedWeight <= svRewardWeight) + pure $ + if remainingWeight > 0 + then beneficiaries <> [(hostedSv, remainingWeight)] + else beneficiaries newRewardStateCid <- create rewardState with state = RewardState with @@ -1577,9 +1703,9 @@ template DsoRules with numRoundsCollected = state.numRoundsCollected + 1 numRoundsMissed = state.numRoundsMissed + (openRound.round.number - state.lastRoundCollected.number - 1) - numCouponsIssued = state.numCouponsIssued + length beneficiaries' + numCouponsIssued = state.numCouponsIssued + length beneficiaries - couponCids <- forA beneficiaries' $ \(beneficiary, weight) -> + couponCids <- forA beneficiaries $ \(beneficiary, weight) -> create SvRewardCoupon with dso sv = hostedSv @@ -1591,6 +1717,72 @@ template DsoRules with pure DsoRules_ReceiveSvRewardCoupon_V2Result with svRewardStatesWithSvRewardCoupons + -- nonconsuming choice DsoRules_ReceiveSvRewardCoupon_V2 : DsoRules_ReceiveSvRewardCoupon_V2Result + -- with + -- sv : Party + -- openRoundCid : ContractId OpenMiningRound + -- -- SvRewardStates to collect rewards for in this call, each with an optional beneficiary split. + -- -- Each SvRewardState must correspond to an SV operated by this SV operator. + -- -- If the beneficiary list is empty, the corresponding SV receives one coupon with the full weight. + -- -- Otherwise, one coupon is issued per listed beneficiary, and the listed weights must sum to the SV's reward weight. + -- rewardStatesWithBeneficiaries : [(ContractId SvRewardState, [(Party, Int)])] + -- controller sv + -- do + -- case Map.lookup sv svs of + -- None -> fail "SV is not an SV operator" + -- Some _ -> do + -- -- check round + -- now <- getTime + -- openRound <- fetchReferenceData (ForDso with dso) openRoundCid + -- require "OpenRound is open" (openRound.opensAt <= now) + + -- require "rewardStates are unique" (unique $ map fst rewardStatesWithBeneficiaries) + + -- svRewardStatesWithSvRewardCoupons <- + -- forA rewardStatesWithBeneficiaries $ \(rewardStateCid, beneficiaries) -> do + -- -- check and update state + -- rewardState <- fetchAndArchive (ForDso with dso) rewardStateCid + -- let state = rewardState.state + -- require + -- ("Round " <> show openRound.round <> " is greater than the last round a reward has been received for " <> show state.lastRoundCollected) + -- (state.lastRoundCollected < openRound.round) + + -- case lookupHostedSvInfoByName rewardState.svName this of + -- None -> fail $ show rewardState.svName <> " is not onboarded" + -- Some (hostedSv, hostedSvInfo) -> do + -- -- check SV operator + -- require + -- ("Only SV operator " <> show hostedSvInfo.svOperator <> " can create SV rewards for SV " <> show hostedSv) + -- (hostedSvInfo.svOperator == sv) + + -- let svRewardWeight = hostedSvInfo.svRewardWeight + -- beneficiaries' <- case beneficiaries of + -- [] -> pure [(hostedSv, svRewardWeight)] + -- _ -> do + -- require "Total beneficiary weight matches SV weight" $ sum (map snd beneficiaries) == svRewardWeight + -- require "At most one coupon per beneficiary" (unique $ map fst beneficiaries) + -- pure beneficiaries + + -- newRewardStateCid <- create rewardState with + -- state = RewardState with + -- lastRoundCollected = openRound.round + -- numRoundsCollected = state.numRoundsCollected + 1 + -- numRoundsMissed = + -- state.numRoundsMissed + (openRound.round.number - state.lastRoundCollected.number - 1) + -- numCouponsIssued = state.numCouponsIssued + length beneficiaries' + + -- couponCids <- forA beneficiaries' $ \(beneficiary, weight) -> + -- create SvRewardCoupon with + -- dso + -- sv = hostedSv + -- beneficiary + -- weight + -- round = openRound.round + + -- pure (newRewardStateCid, couponCids) + + -- pure DsoRules_ReceiveSvRewardCoupon_V2Result with svRewardStatesWithSvRewardCoupons + -- Batch expiry of unclaimed rewards for a specific claimed round ----------------------------------------------------------------- @@ -2226,6 +2418,12 @@ instance HasCheckedFetch UnallocatedUnclaimedActivityRecord ForDso where instance HasCheckedFetch Confirmation ForDso where contractGroupId Confirmation {..} = ForDso with dso +instance HasCheckedFetch SvRewardBeneficiaries ForOwner where + contractGroupId SvRewardBeneficiaries {..} = ForOwner with dso; owner = sv + +instance HasCheckedFetch SvRewardBeneficiaries ForDso where + contractGroupId SvRewardBeneficiaries {..} = ForDso with dso + instance Patchable DsoRulesConfig where patch new base current = DsoRulesConfig with numUnclaimedRewardsThreshold = patch new.numUnclaimedRewardsThreshold base.numUnclaimedRewardsThreshold current.numUnclaimedRewardsThreshold From 72797c343fb5b41e208cd7dce5226041b292f741 Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Thu, 21 May 2026 09:08:34 +0000 Subject: [PATCH 06/19] leftover Signed-off-by: Jose Velasco --- .../daml/Splice/DsoRules.daml | 67 ------------------- 1 file changed, 67 deletions(-) diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 3cf5177f4d..7845c48bbe 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -1717,73 +1717,6 @@ template DsoRules with pure DsoRules_ReceiveSvRewardCoupon_V2Result with svRewardStatesWithSvRewardCoupons - -- nonconsuming choice DsoRules_ReceiveSvRewardCoupon_V2 : DsoRules_ReceiveSvRewardCoupon_V2Result - -- with - -- sv : Party - -- openRoundCid : ContractId OpenMiningRound - -- -- SvRewardStates to collect rewards for in this call, each with an optional beneficiary split. - -- -- Each SvRewardState must correspond to an SV operated by this SV operator. - -- -- If the beneficiary list is empty, the corresponding SV receives one coupon with the full weight. - -- -- Otherwise, one coupon is issued per listed beneficiary, and the listed weights must sum to the SV's reward weight. - -- rewardStatesWithBeneficiaries : [(ContractId SvRewardState, [(Party, Int)])] - -- controller sv - -- do - -- case Map.lookup sv svs of - -- None -> fail "SV is not an SV operator" - -- Some _ -> do - -- -- check round - -- now <- getTime - -- openRound <- fetchReferenceData (ForDso with dso) openRoundCid - -- require "OpenRound is open" (openRound.opensAt <= now) - - -- require "rewardStates are unique" (unique $ map fst rewardStatesWithBeneficiaries) - - -- svRewardStatesWithSvRewardCoupons <- - -- forA rewardStatesWithBeneficiaries $ \(rewardStateCid, beneficiaries) -> do - -- -- check and update state - -- rewardState <- fetchAndArchive (ForDso with dso) rewardStateCid - -- let state = rewardState.state - -- require - -- ("Round " <> show openRound.round <> " is greater than the last round a reward has been received for " <> show state.lastRoundCollected) - -- (state.lastRoundCollected < openRound.round) - - -- case lookupHostedSvInfoByName rewardState.svName this of - -- None -> fail $ show rewardState.svName <> " is not onboarded" - -- Some (hostedSv, hostedSvInfo) -> do - -- -- check SV operator - -- require - -- ("Only SV operator " <> show hostedSvInfo.svOperator <> " can create SV rewards for SV " <> show hostedSv) - -- (hostedSvInfo.svOperator == sv) - - -- let svRewardWeight = hostedSvInfo.svRewardWeight - -- beneficiaries' <- case beneficiaries of - -- [] -> pure [(hostedSv, svRewardWeight)] - -- _ -> do - -- require "Total beneficiary weight matches SV weight" $ sum (map snd beneficiaries) == svRewardWeight - -- require "At most one coupon per beneficiary" (unique $ map fst beneficiaries) - -- pure beneficiaries - - -- newRewardStateCid <- create rewardState with - -- state = RewardState with - -- lastRoundCollected = openRound.round - -- numRoundsCollected = state.numRoundsCollected + 1 - -- numRoundsMissed = - -- state.numRoundsMissed + (openRound.round.number - state.lastRoundCollected.number - 1) - -- numCouponsIssued = state.numCouponsIssued + length beneficiaries' - - -- couponCids <- forA beneficiaries' $ \(beneficiary, weight) -> - -- create SvRewardCoupon with - -- dso - -- sv = hostedSv - -- beneficiary - -- weight - -- round = openRound.round - - -- pure (newRewardStateCid, couponCids) - - -- pure DsoRules_ReceiveSvRewardCoupon_V2Result with svRewardStatesWithSvRewardCoupons - - -- Batch expiry of unclaimed rewards for a specific claimed round ----------------------------------------------------------------- nonconsuming choice DsoRules_ClaimExpiredRewards : DsoRules_ClaimExpiredRewardsResult From 1fe975b35b47f42950b9262378c0da2b25df4347 Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Fri, 22 May 2026 15:09:16 +0000 Subject: [PATCH 07/19] add daml test with happy path scenarios Signed-off-by: Jose Velasco --- .../daml/Splice/Scripts/DsoTestUtils.daml | 7 + .../daml/Splice/Scripts/TestSvWeights.daml | 396 ++++++++++++++++++ .../daml/Splice/DsoBootstrap.daml | 1 + .../daml/Splice/DsoRules.daml | 63 ++- 4 files changed, 449 insertions(+), 18 deletions(-) create mode 100644 daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml diff --git a/daml/splice-dso-governance-test/daml/Splice/Scripts/DsoTestUtils.daml b/daml/splice-dso-governance-test/daml/Splice/Scripts/DsoTestUtils.daml index a31a177655..1872d8bbf8 100644 --- a/daml/splice-dso-governance-test/daml/Splice/Scripts/DsoTestUtils.daml +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/DsoTestUtils.daml @@ -167,6 +167,13 @@ getSvInfoByParty app sv = do None -> fail $ "Not a sv: " <> show sv Some info -> pure info +getHostedSvInfoByParty : AmuletApp -> Party -> Script HostedSvInfo +getHostedSvInfoByParty app sv = do + [(_, rules)] <- query @DsoRules app.dso + case Map.lookup sv (fromOptional Map.empty rules.hostedSvs) of + None -> fail $ "Not a hosted sv: " <> show sv + Some info -> pure info + generateUnclaimedReward : AmuletApp -> AmuletUser -> Script () generateUnclaimedReward app provider1 = do submit (actAs app.dso <> actAs provider1.primaryParty) $ createCmd AppRewardCoupon with diff --git a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml new file mode 100644 index 0000000000..9bcee1ea22 --- /dev/null +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml @@ -0,0 +1,396 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +module Splice.Scripts.TestSvWeights where + +import DA.Assert +import DA.Action (void) +import DA.List (sort) +import DA.Map qualified as Map +import DA.Optional (fromOptional) + +import Daml.Script + +import Splice.Amulet +import Splice.Round +import Splice.Scripts.Util +import Splice.DsoRules +import Splice.DSO.SvState +import Splice.Scripts.DsoTestUtils +import Splice.Testing.Registries.AmuletRegistry (getActiveOpenRoundsSorted) + + +test_SvWeightsFullyOnledger : Script () +test_SvWeightsFullyOnledger = do + + -- Setup + --------- + (app, dso, (sv1, sv2, sv3, sv4)) <- initMainNet + -- ghostSv is hosted by SV operator sv1 + let ghostSvName = "Ghost-SV" + ghostSv <- allocateParty ghostSvName + -- hostedSv is hosted by SV operator sv1 + let hostedSvName = "Hosted-SV" + hostedSv <- allocateParty hostedSvName + + -- sv1's extrabeneficary + sv1_b <- allocateParty "SV_1 extra beneficiary" + -- hostedSv's extrabeneficary + hostedSv_b <- allocateParty "Hosted_SV extra beneficiary" + + let sv1TotalWeight = 100_000 + initiateAndAcceptVote app [sv1, sv2, sv3, sv4] $ + ARC_DsoRules with + dsoAction = SRARC_UpdateSvRewardWeight DsoRules_UpdateSvRewardWeight with + svParty = sv1 + newRewardWeight = sv1TotalWeight + + sv1SvInfo <- getSvInfoByParty app sv1 + + let sv1LegacyBeneficiaries = + [ (sv1, 10_000) + , (ghostSv, 20_000) + , (hostedSv, 30_000) + , (sv1_b, 15_000) + , (hostedSv_b, 25_000) + ] + sv1LegacyBeneficiariesMap = Map.fromList sv1LegacyBeneficiaries + beneficiariesFromLegacy parties = map (\p -> (p, unsafeLookup p sv1LegacyBeneficiariesMap)) parties + + + -- Creating SV coupons using the legacy DsoRules_ReceiveSvRewardCoupon + ----------------------------------------------------------------------- + + [(rulesCid, _)] <- query @DsoRules app.dso + [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso + [(sv1RewardStateCid, _)] <- queryFilter @SvRewardState dso (\s -> s.svName == sv1SvInfo.name) + void $ submit (actAs sv1 <> readAs dso) $ exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon with + openRoundCid = roundCid + rewardStateCid = sv1RewardStateCid + beneficiaries = sv1LegacyBeneficiaries + sv = sv1 + checkSvCouponBeneficariesBySv sv1 round sv1LegacyBeneficiaries + + + -- SV migration + ---------------- + + runNextIssuance app + [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso + + let totalWeight beneficiaries = sum (map snd beneficiaries) + sv1Beneficiaries = beneficiariesFromLegacy [sv1, sv1_b] + hostedSvBeneficiaries = beneficiariesFromLegacy [hostedSv, hostedSv_b] + ghostSvBeneficiaries = beneficiariesFromLegacy [ghostSv] + + sv1Weight = totalWeight sv1Beneficiaries + hostedSvWeight = totalWeight hostedSvBeneficiaries + ghostSvWeight = totalWeight ghostSvBeneficiaries + + sv1AsHostedSvName = sv1SvInfo.name <> "-hostedSV" + svsMigrationData = + [ mkSvMigrationData sv1 sv1AsHostedSvName sv1SvInfo.participantId sv1Weight -- sv1 itself is migrated as hosted SV + , mkSvMigrationData hostedSv hostedSvName sv1SvInfo.participantId hostedSvWeight + , mkSvMigrationData ghostSv ghostSvName sv1SvInfo.participantId ghostSvWeight + ] + + -- Migrate sv1, hostedSv and ghostSv + [(sv1RewardStateCid, _)] <- queryFilter @SvRewardState dso (\s -> s.svName == sv1SvInfo.name) + void $ submit (actAs sv1 <> readAs dso) $ exerciseCmd rulesCid DsoRules_MigrateHostedSvs with + svsMigrationData + openRoundCid = roundCid + rewardStateCid = sv1RewardStateCid + sv = sv1 + + -- Check sv1 weight as SV operator is invalidated + sv1SvInfo <- getSvInfoByParty app sv1 + sv1SvInfo.svRewardWeight === 0 + -- Check SvRewardState has been archived + [] <- queryFilter @SvRewardState dso (\s -> s.svName == sv1SvInfo.name) + -- Check hosted SVs + [(rulesCid, rules)] <- query @DsoRules app.dso + Map.size (getHostedSvsMap rules) === 3 + checkDsoRulesHostedSv app sv1 (mkHostedSvInfo sv1AsHostedSvName round sv1Weight sv1SvInfo.participantId sv1) + checkDsoRulesHostedSv app hostedSv (mkHostedSvInfo hostedSvName round hostedSvWeight sv1SvInfo.participantId sv1) + checkDsoRulesHostedSv app ghostSv (mkHostedSvInfo ghostSvName round ghostSvWeight sv1SvInfo.participantId sv1) + -- Check hosted SVs SvRewardState contracts + checkSvRewardStateExists app sv1AsHostedSvName + checkSvRewardStateExists app hostedSvName + checkSvRewardStateExists app hostedSvName + + -- Migrate beneficaries weights + void $ submit (actAs sv1 <> readAs dso) $ exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with + action = SetBeneficiaries with + optCid = None + beneficiaries = [(sv1_b, unsafeLookup sv1_b sv1LegacyBeneficiariesMap)] + svParty = sv1 + void $ submit (actAs hostedSv <> readAs dso) $ exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with + action = SetBeneficiaries with + optCid = None + beneficiaries = [(hostedSv_b, unsafeLookup hostedSv_b sv1LegacyBeneficiariesMap)] + svParty = hostedSv + + + -- Creating SV coupons using the new DsoRules_ReceiveSvRewardCoupon_V2 + ----------------------------------------------------------------------- + + -- Note: The legacy DsoRules_ReceiveSvRewardCoupon flow for sv1 as SV operator is invalidated once the migration becomes effective. + + [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso + rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app sv1 + void $ submit (actAs sv1 <> readAs dso) $ + exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with + openRoundCid = roundCid + rewardStatesWithBeneficiaries + sv = sv1 + + checkSvCouponBeneficariesBySv sv1 round sv1Beneficiaries + checkSvCouponBeneficariesBySv hostedSv round hostedSvBeneficiaries + checkSvCouponBeneficariesBySv ghostSv round ghostSvBeneficiaries + + + -- Updating SV weight + Updating beneficaries weight + ----------------------------------------------------- + -- Reduce sv1's total reward weight. In this test, the reduction is applied to + -- sv1_b's beneficiary share so the beneficiary distribution matches the new total. + + runNextIssuance app + + let sv1BeneficiariesMap = Map.fromList sv1Beneficiaries + sv1BWeightDelta = -10 + updatedSv1BWeight = unsafeLookup sv1_b sv1BeneficiariesMap + sv1BWeightDelta + updatedSv1Beneficiaries = + [ (sv1, unsafeLookup sv1 sv1BeneficiariesMap) + , (sv1_b, updatedSv1BWeight) + ] + updatedSv1Weight = totalWeight updatedSv1Beneficiaries + updatedSv1ExtraBeneficiaries = [(sv1_b, updatedSv1BWeight)] + + -- Update sv1 weight (this includes the weight shared with sv1_b) + initiateAndAcceptVote app [sv1, sv2, sv3, sv4] $ + ARC_DsoRules with + dsoAction = SRARC_UpdateSvRewardWeight_V2 DsoRules_UpdateSvRewardWeight_V2 with + svParty = sv1 + newRewardWeight = updatedSv1Weight + + -- Update sv1's beneficaries weight + [(rulesCid, _)] <- query @DsoRules app.dso + [(sv1SvRewardBeneficiariesCid, _)] <- + queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == sv1 && b.svOperator == sv1) + void $ submit (actAs sv1 <> readAs dso) $ + exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with + action = SetBeneficiaries with + optCid = Some sv1SvRewardBeneficiariesCid + beneficiaries = updatedSv1ExtraBeneficiaries + svParty = sv1 + + -- Create SV coupons + [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso + rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app sv1 + void $ submit (actAs sv1 <> readAs dso) $ + exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with + openRoundCid = roundCid + rewardStatesWithBeneficiaries + sv = sv1 + + checkSvCouponBeneficariesBySv sv1 round updatedSv1Beneficiaries + checkSvCouponBeneficariesBySv hostedSv round hostedSvBeneficiaries + checkSvCouponBeneficariesBySv ghostSv round ghostSvBeneficiaries + + + -- Removing SV beneficiaries + ----------------------------- + -- Remove sv1_b's weight, so sv1 receives all the SV coupons + + runNextIssuance app + + let sv1BeneficiariesAfterClear = [(sv1, updatedSv1Weight)] + + -- Remove sv1_b's weight + [(sv1SvRewardBeneficiariesCid, _)] <- + queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == sv1 && b.svOperator == sv1) + void $ submit (actAs sv1 <> readAs dso) $ + exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with + action = ClearBeneficiaries with + cid = sv1SvRewardBeneficiariesCid + svParty = sv1 + + -- Create SV coupons + [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso + rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app sv1 + void $ submit (actAs sv1 <> readAs dso) $ + exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with + openRoundCid = roundCid + rewardStatesWithBeneficiaries + sv = sv1 + + checkSvCouponBeneficariesBySv sv1 round sv1BeneficiariesAfterClear + checkSvCouponBeneficariesBySv hostedSv round hostedSvBeneficiaries + checkSvCouponBeneficariesBySv ghostSv round ghostSvBeneficiaries + + + -- Onboarding an SV node + ------------------------- + + let newSvOperatorAlias = "new-SV-operator" + newSvNodeName = newSvOperatorAlias <> "-node-1" + newSvNodeParticipantId = newSvNodeName <> "-participant-id" + newSvOperator <- allocateParty newSvOperatorAlias + + initiateAndAcceptVote app [sv1, sv2, sv3, sv4] $ + ARC_DsoRules with + dsoAction = SRARC_AddSvNode DsoRules_AddSvNode with + newSvOperatorParty = newSvOperator + newSvNodeName + newSvNodeParticipantId + + + -- Offboarding/Onboarding a hosted SV + -------------------------------------- + -- Migrate hostedSv from sv1 to newSvOperator + -- To do that, we need to offboard hostedSv and then onboard it again under newSvOperator + + runNextIssuance app + + -- Offboard hostedSv + initiateAndAcceptVote app [sv1, sv2, sv3, sv4, newSvOperator] $ + ARC_DsoRules with + dsoAction = SRARC_OffboardHostedSv DsoRules_OffboardHostedSv with + svParty = hostedSv + + -- Add hostedSv with newSvOperator as SV operator + newSvOperatorSvInfo <- getSvInfoByParty app newSvOperator + [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso + initiateAndAcceptVote app [sv1, sv2, sv3, sv4, newSvOperator] $ + ARC_DsoRules with + dsoAction = SRARC_AddHostedSv DsoRules_AddHostedSv with + newSvParty = hostedSv + newSvName = hostedSvName + newSvRewardWeight = hostedSvWeight + newSvParticipantId = newSvOperatorSvInfo.participantId + joinedAsOfRound = round.round + svOperator = newSvOperator + + -- hostedSv removes (ClearBeneficiaries) SvRewardBeneficiaries contract where sv1 is the SV operator + [(rulesCid, _)] <- query @DsoRules app.dso + [(hostedSvSvRewardBeneficiariesCid, _)] <- + queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == hostedSv && b.svOperator == sv1) + void $ submit (actAs hostedSv <> readAs dso) $ + exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with + action = ClearBeneficiaries with + cid = hostedSvSvRewardBeneficiariesCid + svParty = hostedSv + + -- hostedSv recreates SvRewardBeneficiaries contract with newSvOperator as SV operator + void $ submit (actAs hostedSv <> readAs dso) $ exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with + action = SetBeneficiaries with + optCid = None + beneficiaries = [(hostedSv_b, unsafeLookup hostedSv_b sv1LegacyBeneficiariesMap)] + svParty = hostedSv + + -- sv1 as SV operator creates SV coupons + [(rulesCid, _)] <- query @DsoRules app.dso + rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app sv1 + void $ submit (actAs sv1 <> readAs dso) $ + exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with + openRoundCid = roundCid + rewardStatesWithBeneficiaries + sv = sv1 + + checkNoSvCouponsForSvInRound hostedSv round + checkSvCouponBeneficariesBySv sv1 round sv1BeneficiariesAfterClear + checkSvCouponBeneficariesBySv ghostSv round ghostSvBeneficiaries + + -- newSvOperator as SV operator creates SV coupons + rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app newSvOperator + void $ submit (actAs newSvOperator <> readAs dso) $ + exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with + openRoundCid = roundCid + rewardStatesWithBeneficiaries + sv = newSvOperator + + checkSvCouponBeneficariesBySv hostedSv round hostedSvBeneficiaries + + + -- Offboarding an SV node + -------------------------- + -- Assuming that all hosted SV operated by the SV node has been migrated to a new SV node + + initiateAndAcceptVote app [sv1, sv2, sv3, newSvOperator] $ + ARC_DsoRules with + dsoAction = SRARC_OffboardSv DsoRules_OffboardSv with + sv = sv4 + + pure () + + + +-- Helpers +----------- + +getHostedSvsMap : DsoRules -> Map.Map Party HostedSvInfo +getHostedSvsMap rules = fromOptional Map.empty rules.hostedSvs + +checkSvCouponBeneficariesBySv : Party -> OpenMiningRound -> [(Party, Int)] -> Script () +checkSvCouponBeneficariesBySv sv round expected = do + (_, coupons) <- unzip <$> queryFilter @SvRewardCoupon sv (\co -> co.round == round.round && co.sv == sv) + let beneficiaries = map (\coupon -> (coupon.beneficiary, coupon.weight)) coupons + sort beneficiaries === sort expected + +checkNoSvCouponsForSvInRound : Party -> OpenMiningRound -> Script () +checkNoSvCouponsForSvInRound sv round = do + [] <- queryFilter @SvRewardCoupon sv (\co -> co.round == round.round && co.sv == sv) + pure () + +checkSvCouponBeneficariesInRound : AmuletApp -> OpenMiningRound -> [(Party, Int)] -> Script () +checkSvCouponBeneficariesInRound app round expected = do + (_, coupons) <- unzip <$> queryFilter @SvRewardCoupon app.dso (\co -> co.round == round.round) + let beneficiaries = map (\coupon -> (coupon.beneficiary, coupon.weight)) coupons + sort beneficiaries === sort expected + +checkDsoRulesHostedSv : AmuletApp -> Party -> HostedSvInfo -> Script () +checkDsoRulesHostedSv app hostedSv expected = do + hostedSvInfo <- getHostedSvInfoByParty app hostedSv + hostedSvInfo === expected + +mkHostedSvInfo : Text -> OpenMiningRound -> Int -> Text -> Party -> HostedSvInfo +mkHostedSvInfo name joinedAsOfRound svRewardWeight participantId svOperator = + HostedSvInfo with joinedAsOfRound = joinedAsOfRound.round; .. + +mkSvMigrationData : Party -> Text -> Text -> Int -> SvMigrationData +mkSvMigrationData svParty svName svParticipantId svRewardWeight = + SvMigrationData with .. + +unsafeLookup : (Ord k, Show k) => k -> Map.Map k v -> v +unsafeLookup key' m = + case Map.lookup key' m of + Some value -> value + None -> error $ "Key not found: " <> show key' + +checkSvRewardStateExists : AmuletApp -> Text -> Script () +checkSvRewardStateExists app svName = do + [_] <- queryFilter @SvRewardState app.dso (\s -> s.svName == svName) + pure () + +getRewardStatesWithBeneficiariesBySvOperator + : AmuletApp + -> Party + -> Script [(ContractId SvRewardState, Optional (ContractId SvRewardBeneficiaries))] +getRewardStatesWithBeneficiariesBySvOperator app svOperator = do + [(_, rules)] <- query @DsoRules app.dso + let hostedSvsMap = getHostedSvsMap rules + hostedSvsOperatedBySvOperator = + filter (\(_, hostedSvInfo) -> hostedSvInfo.svOperator == svOperator) $ + Map.toList hostedSvsMap + + forA hostedSvsOperatedBySvOperator $ \(hostedSvParty, hostedSvInfo) -> do + [(rewardStateCid, _)] <- queryFilter @SvRewardState app.dso (\s -> s.svName == hostedSvInfo.name) + svRewardBeneficiariesCids <- map fst <$> + queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == hostedSvParty && b.svOperator == svOperator) + pure + ( rewardStateCid + , case svRewardBeneficiariesCids of + [] -> None + [cid] -> Some cid + _ -> fail "Expected at most one SvRewardBeneficiaries contract" + ) diff --git a/daml/splice-dso-governance/daml/Splice/DsoBootstrap.daml b/daml/splice-dso-governance/daml/Splice/DsoBootstrap.daml index d37bb95b26..92fab4e2be 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoBootstrap.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoBootstrap.daml @@ -75,6 +75,7 @@ template DsoBootstrap with initialTrafficState isDevNet hostedSvs = None + offboardedHostedSvs = None create dsoRules -- create initial per-sv and per-operator contracts for sv1 let addSvChoiceArgs = DsoRules_AddSv with diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 7845c48bbe..0a8b9e076c 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -119,7 +119,7 @@ data DsoRules_ActionRequiringConfirmation -- ^ Voted action to directly add a hosted SV. | SRARC_UpdateSvRewardWeight_V2 DsoRules_UpdateSvRewardWeight_V2 -- ^ Voted action to update the weight of a hosted SV. - | SRARC_RemoveHostedSv DsoRules_RemoveHostedSv + | SRARC_OffboardHostedSv DsoRules_OffboardHostedSv -- ^ Voted action to remove a hosted SV. | SRARC_AddSvNode DsoRules_AddSvNode -- ^ Voted action to directly add an SV node. @@ -185,7 +185,6 @@ data DsoRules_AddSvOperatorResult = DsoRules_AddSvOperatorResult with newDsoRules : ContractId DsoRules data DsoRules_AddHostedSvResult = DsoRules_AddHostedSvResult with - svRewardStateCid : ContractId SvRewardState newDsoRules : ContractId DsoRules data DsoRules_OffboardSvResult = DsoRules_OffboardSvResult with @@ -600,6 +599,7 @@ template DsoRules with initialTrafficState: Map.Map Text TrafficState -- ^ Map from participant/mediator ID to its traffic state at the time of synchronizer bootstrapping. Used for testing, empty in prod. isDevNet : Bool hostedSvs : Optional (Map.Map Party HostedSvInfo) -- ^ List of hosted SVs. + offboardedHostedSvs : Optional (Map.Map Party OffboardedSvInfo) where ensure config.numUnclaimedRewardsThreshold > 0 @@ -665,6 +665,7 @@ template DsoRules with initialTrafficState isDevNet hostedSvs + offboardedHostedSvs return DsoRules_OffboardSvResult with .. @@ -682,14 +683,15 @@ template DsoRules with let isSvOperator = flip Map.member svs require "svOperator is an SV operator" $ isSvOperator svOperator - svRewardStateCid <- create SvRewardState with - dso - svName = newSvName - state = RewardState with - lastRoundCollected = Round (joinedAsOfRound.number - 1) - numRoundsMissed = 0 - numRoundsCollected = 0 - numCouponsIssued = 0 + unless (hostedSvHasBeenOnboardedBefore newSvName this) $ + void $ create SvRewardState with + dso + svName = newSvName + state = RewardState with + lastRoundCollected = Round (joinedAsOfRound.number - 1) + numRoundsMissed = 0 + numRoundsCollected = 0 + numCouponsIssued = 0 -- register the new SV in the DsoRules let hostedSvInfo = HostedSvInfo with @@ -702,7 +704,7 @@ template DsoRules with newDsoRules <- create this with hostedSvs = Some updatedHostedSvs - pure DsoRules_AddHostedSvResult with newDsoRules; svRewardStateCid + pure DsoRules_AddHostedSvResult with newDsoRules -- Update an SV's Status report nonconsuming choice DsoRules_SubmitStatusReport : DsoRules_SubmitStatusReportResult @@ -1147,20 +1149,25 @@ template DsoRules with newDsoRules <- create this with hostedSvs = Some newHostedSvs pure DsoRules_UpdateSvRewardWeight_V2Result with newDsoRules - choice DsoRules_RemoveHostedSv : DsoRules_RemoveHostedSvResult + choice DsoRules_OffboardHostedSv : DsoRules_RemoveHostedSvResult with svParty : Party controller dso do let hostedSvsMap = fromOptional Map.empty hostedSvs + offboardedHostedSvsMap = fromOptional Map.empty offboardedHostedSvs case Map.lookup svParty hostedSvsMap of None -> fail "SV party is not registered" - Some _ -> do + Some hostedSvInfo -> do let newHostedSvs = Map.delete svParty hostedSvsMap - newDsoRules <- create this with hostedSvs = Some newHostedSvs + offboardedSvInfo = OffboardedSvInfo with + name = hostedSvInfo.name + participantId = hostedSvInfo.participantId + newDsoRules <- create this with + hostedSvs = Some newHostedSvs + offboardedHostedSvs = Some $ Map.insert svParty offboardedSvInfo offboardedHostedSvsMap pure DsoRules_RemoveHostedSvResult with newDsoRules - -- | Sets or clears the SV reward beneficiary distribution for an SV. -- -- This choice is optional for SVs that want to share their SV rewards with @@ -1203,7 +1210,16 @@ template DsoRules with pure DsoRules_SetSvRewardBeneficiariesResult with svRewardBeneficiariesCid -- TODO: Deprecate once all SVs have migrated to `hostedSvs`. - -- DRAFT note for the reviewer: Please check the migration procedure in the PR description. + -- + -- Trust assumption: + -- The SV operator is trusted to exercise this choice with the correct migration data. + -- This choice validates that the total migrated weight matches the SV operator's legacy + -- reward weight, but it does not independently verify that the per-SV split matches the + -- SV operator's off-ledger configuration. + -- + -- This is consistent with the current model, where the SV operator already configures + -- the reward weights in its SV app. Therefore, the SVs operated by this SV operator + -- already rely on it to use the correct reward distribution. choice DsoRules_MigrateHostedSvs : DsoRules_MigrateHostedSvsResult with -- Complete on-ledger SV reward weight distribution for the SVs operated by @@ -1222,6 +1238,7 @@ template DsoRules with -- configured later through 'SvRewardBeneficiaries'. svsMigrationData : [SvMigrationData] openRoundCid : ContractId OpenMiningRound + rewardStateCid : ContractId SvRewardState sv : Party -- ^ The SV operator performing the migration. controller sv do @@ -1243,6 +1260,9 @@ template DsoRules with newSvs = Map.insert sv newSv svs updatedDsoRulesWithLegacyWeightInvalidated <- create this with svs = newSvs + -- Archive SvRewardState + _ <- fetchAndArchive (ForSv with dso; svName = svInfo.name) rewardStateCid + -- Add hosted SVs let addHostedSv self' svMigrationData = do DsoRules_AddHostedSvResult {newDsoRules} <- exercise self' DsoRules_AddHostedSv with @@ -1634,7 +1654,6 @@ template DsoRules with nonconsuming choice DsoRules_ReceiveSvRewardCoupon_V2 : DsoRules_ReceiveSvRewardCoupon_V2Result with - sv : Party openRoundCid : ContractId OpenMiningRound -- SvRewardStates to collect rewards for in this call, each with an optional -- beneficiary distribution. @@ -1646,6 +1665,7 @@ template DsoRules with -- configured beneficiary. The total beneficiary weight must be less than or -- equal to the SV weight. Any remaining weight is issued to the SV itself. rewardStatesWithBeneficiaries : [(ContractId SvRewardState, Optional (ContractId SvRewardBeneficiaries))] + sv : Party -- ^ The SV operator hosting the SVs. controller sv do case Map.lookup sv svs of @@ -2085,7 +2105,7 @@ executeActionRequiringConfirmation dso dsoRulesCid amuletRulesCid act = case act SRARC_CreateBootstrapExternalPartyConfigStateInstruction choiceArg -> void $ exercise dsoRulesCid choiceArg SRARC_AddHostedSv choiceArg -> void $ exercise dsoRulesCid choiceArg SRARC_UpdateSvRewardWeight_V2 choiceArg -> void $ exercise dsoRulesCid choiceArg - SRARC_RemoveHostedSv choiceArg -> void $ exercise dsoRulesCid choiceArg + SRARC_OffboardHostedSv choiceArg -> void $ exercise dsoRulesCid choiceArg SRARC_AddSvNode choiceArg -> void $ exercise dsoRulesCid choiceArg ARC_AnsEntryContext with .. -> do void $ fetchChecked (ForDso with dso) ansEntryContextCid @@ -2169,6 +2189,13 @@ svHasBeenOnboardedBefore : Text -> DsoRules -> Bool svHasBeenOnboardedBefore svName this = isSome (lookupSvInfoByName svName this) || any (\info -> info.name == svName) (Map.values this.offboardedSvs) +-- | Returns True if a hosted SV with that name is either currently onboarded +-- or a hosted SV with that name has been onboarded before and is now in offboardedHostedSvs. +hostedSvHasBeenOnboardedBefore : Text -> DsoRules -> Bool +hostedSvHasBeenOnboardedBefore svName this = + let offboardedHostedSvsMap = fromOptional Map.empty this.offboardedHostedSvs + in isSome (lookupHostedSvInfoByName svName this) || any (\info -> info.name == svName) (Map.values offboardedHostedSvsMap) + ensureNeverOperatedNode : Party -> DsoRules -> Update () ensureNeverOperatedNode newSvParty this = do require "SV party has not yet operated a node" $ From 2fcf87c48b36eef89d576237bd541d12f6b15438 Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Wed, 27 May 2026 09:20:11 +0100 Subject: [PATCH 08/19] Update daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml Co-authored-by: Itai Segall Signed-off-by: Jose Velasco --- .../daml/Splice/Scripts/TestSvWeights.daml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml index 9bcee1ea22..f1c070b7f7 100644 --- a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml @@ -116,7 +116,7 @@ test_SvWeightsFullyOnledger = do -- Check hosted SVs SvRewardState contracts checkSvRewardStateExists app sv1AsHostedSvName checkSvRewardStateExists app hostedSvName - checkSvRewardStateExists app hostedSvName + checkSvRewardStateExists app ghostSvName -- Migrate beneficaries weights void $ submit (actAs sv1 <> readAs dso) $ exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with From d4fba7e445dccab2e30334a80f24328096582afb Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Wed, 27 May 2026 12:17:38 +0000 Subject: [PATCH 09/19] split test (target flow + migration) Signed-off-by: Jose Velasco --- .../daml/Splice/Scripts/TestSvWeights.daml | 482 +++++++++++------- 1 file changed, 303 insertions(+), 179 deletions(-) diff --git a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml index f1c070b7f7..14bda2b515 100644 --- a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml @@ -23,6 +23,283 @@ import Splice.Testing.Registries.AmuletRegistry (getActiveOpenRoundsSorted) test_SvWeightsFullyOnledger : Script () test_SvWeightsFullyOnledger = do + -- Setup + --------- + + (app, dso, (sv1, sv2, sv3, sv4)) <- initMainNet + let newSvOperatorAlias = "new-SV-operator" + newSvNodeName = newSvOperatorAlias <> "-node-1" + newSvNodeParticipantId = newSvNodeName <> "-participant-id" + -- hostedSv is hosted by SV operator newSvOperator + hostedSvName = "Hosted-SV" + + newSvOperator <- allocateParty newSvOperatorAlias + hostedSv <- allocateParty hostedSvName + -- hostedSv's extrabeneficary + hostedSv_b <- allocateParty "Hosted_SV extra beneficiary" + + let hostedSvBeneficiaries = [(hostedSv, 80_000), (hostedSv_b, 20_000)] + hostedSvBeneficiariesMap = Map.fromList hostedSvBeneficiaries + hostedSvWeight = totalWeight hostedSvBeneficiaries + + + -- Onboarding an SV node + ------------------------- + + [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso + initiateAndAcceptVote app [sv1, sv2, sv3, sv4] $ + ARC_DsoRules with + dsoAction = SRARC_AddSvNode DsoRules_AddSvNode with + newSvOperatorParty = newSvOperator + newSvNodeName + newSvNodeParticipantId + + checkDsoRulesSv app newSvOperator (mkSvInfo newSvNodeName round 0 newSvNodeParticipantId newSvOperator) + + + -- Onboarding a hosted SV + -------------------------- + -- Add hostedSv with newSvOperator as SV operator + + newSvOperatorSvInfo <- getSvInfoByParty app newSvOperator + initiateAndAcceptVote app [sv1, sv2, sv3, sv4, newSvOperator] $ + ARC_DsoRules with + dsoAction = SRARC_AddHostedSv DsoRules_AddHostedSv with + newSvParty = hostedSv + newSvName = hostedSvName + newSvRewardWeight = hostedSvWeight + newSvParticipantId = newSvOperatorSvInfo.participantId + joinedAsOfRound = round.round + svOperator = newSvOperator + + checkDsoRulesHostedSv app hostedSv + (mkHostedSvInfo hostedSvName round hostedSvWeight newSvOperatorSvInfo.participantId newSvOperator) + + + -- Creating beneficiary's weight + --------------------------------- + + let hostedSvExtraBeneficiaries = [(hostedSv_b, unsafeLookup hostedSv_b hostedSvBeneficiariesMap)] + [(rulesCid, _)] <- query @DsoRules app.dso + void $ submit (actAs hostedSv <> readAs dso) $ + exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with + action = SetBeneficiaries with + optCid = None + beneficiaries = hostedSvExtraBeneficiaries + svParty = hostedSv + + [(hostedSvSvRewardBeneficiariesCid, hostedSvSvRewardBeneficiaries)] <- + queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == hostedSv && b.svOperator == newSvOperator) + hostedSvSvRewardBeneficiaries === SvRewardBeneficiaries with + dso + sv = hostedSv + svOperator = newSvOperator + beneficiaries = hostedSvExtraBeneficiaries + + + -- Creating SV coupons + ----------------------- + + [(rulesCid, _)] <- query @DsoRules app.dso + [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso + + -- newSvOperator creates coupons using the new DsoRules_ReceiveSvRewardCoupon_V2 + rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app newSvOperator + void $ submit (actAs newSvOperator <> readAs dso) $ + exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with + openRoundCid = roundCid + rewardStatesWithBeneficiaries + sv = newSvOperator + + checkSvCouponBeneficariesBySv hostedSv round hostedSvBeneficiaries + + -- newSvOperator cannot create coupons using the legacy DsoRules_ReceiveSvRewardCoupon as + -- there's no SvRewardState contract + [] <- queryFilter @SvRewardState dso (\s -> s.svName == newSvNodeName) + -- void $ submit (actAs newSvOperator <> readAs dso) $ exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon with + -- openRoundCid = roundCid + -- rewardStateCid = undefined -- No contract + -- beneficiaries = hostedSvBeneficiaries + -- sv = newSvOperator + + -- newSvOperator tries to create coupons using the new DsoRules_ReceiveSvRewardCoupon_V2 for the same round + rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app newSvOperator + void $ submitMustFail (actAs newSvOperator <> readAs dso) $ + exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with + openRoundCid = roundCid + rewardStatesWithBeneficiaries + sv = newSvOperator + + + -- Updating SV weight + Updating beneficaries weight + ----------------------------------------------------- + -- Reduce hostedSv's total reward weight. In this test, the reduction is applied to + -- sv1_b's beneficiary share so the beneficiary distribution matches the new total. + + runNextIssuance app + + let weightDelta = -10 + updatedHostedSvBenWeight = unsafeLookup hostedSv_b hostedSvBeneficiariesMap + weightDelta + updatedHostedSvBeneficiaries = + [ (hostedSv, unsafeLookup hostedSv hostedSvBeneficiariesMap) + , (hostedSv_b, updatedHostedSvBenWeight) + ] + updatedHostedSvWeight = totalWeight updatedHostedSvBeneficiaries + updatedHostedSvExtraBeneficiaries = [(hostedSv_b, updatedHostedSvBenWeight)] + + -- Update hostedSv weight (this includes the weight shared with hostedSv_b) + initiateAndAcceptVote app [sv1, sv2, sv3, sv4, newSvOperator] $ + ARC_DsoRules with + dsoAction = SRARC_UpdateSvRewardWeight_V2 DsoRules_UpdateSvRewardWeight_V2 with + svParty = hostedSv + newRewardWeight = updatedHostedSvWeight + + checkDsoRulesHostedSv app hostedSv + (mkHostedSvInfo hostedSvName round updatedHostedSvWeight newSvOperatorSvInfo.participantId newSvOperator) + + -- Update hostedSv_b's beneficaries weight + [(rulesCid, _)] <- query @DsoRules app.dso + void $ submit (actAs hostedSv <> readAs dso) $ + exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with + action = SetBeneficiaries with + optCid = Some hostedSvSvRewardBeneficiariesCid + beneficiaries = updatedHostedSvExtraBeneficiaries + svParty = hostedSv + + [(hostedSvSvRewardBeneficiariesCid, hostedSvSvRewardBeneficiaries)] <- + queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == hostedSv && b.svOperator == newSvOperator) + hostedSvSvRewardBeneficiaries === SvRewardBeneficiaries with + dso + sv = hostedSv + svOperator = newSvOperator + beneficiaries = updatedHostedSvExtraBeneficiaries + + -- Create SV coupons + [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso + rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app newSvOperator + void $ submit (actAs newSvOperator <> readAs dso) $ + exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with + openRoundCid = roundCid + rewardStatesWithBeneficiaries + sv = newSvOperator + + checkSvCouponBeneficariesBySv hostedSv round updatedHostedSvBeneficiaries + + + -- Removing SV beneficiaries + ----------------------------- + -- Remove hostedSv_b's weight, so hostedSv receives all the SV coupons + + runNextIssuance app + + let hostedSvBeneficiariesAfterClear = [(hostedSv, updatedHostedSvWeight)] + + -- Remove hostedSv_b's weight + void $ submit (actAs hostedSv <> readAs dso) $ + exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with + action = ClearBeneficiaries with + cid = hostedSvSvRewardBeneficiariesCid + svParty = hostedSv + + -- Create SV coupons + [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso + rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app newSvOperator + void $ submit (actAs newSvOperator <> readAs dso) $ + exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with + openRoundCid = roundCid + rewardStatesWithBeneficiaries + sv = newSvOperator + + checkSvCouponBeneficariesBySv hostedSv round hostedSvBeneficiariesAfterClear + + + -- Updating the SV operator of a hosted SV + ------------------------------------------- + -- Migrate hostedSv from snewSvOperatorv1 to sv1 + -- To do that, we need to offboard hostedSv and then onboard it again under the new SV operator + + hostedSvSvInfo <- getHostedSvInfoByParty app hostedSv + runNextIssuance app + [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso + + -- Offboard hostedSv + initiateAndAcceptVote app [sv1, sv2, sv3, sv4, newSvOperator] $ + ARC_DsoRules with + dsoAction = SRARC_OffboardHostedSv DsoRules_OffboardHostedSv with + svParty = hostedSv + + [(_, rules)] <- query @DsoRules app.dso + Map.lookup hostedSv (getHostedSvsMap rules) === None + Map.lookup hostedSv (getOffboardedHostedSvsMap rules) === Some + OffboardedSvInfo with + name = hostedSvSvInfo.name + participantId = hostedSvSvInfo.participantId + + -- Add hostedSv with sv1 as SV operator + sv1SvInfo <- getSvInfoByParty app sv1 + initiateAndAcceptVote app [sv1, sv2, sv3, sv4, newSvOperator] $ + ARC_DsoRules with + dsoAction = SRARC_AddHostedSv DsoRules_AddHostedSv with + newSvParty = hostedSv + newSvName = hostedSvName + newSvRewardWeight = updatedHostedSvWeight + newSvParticipantId = sv1SvInfo.participantId + joinedAsOfRound = round.round + svOperator = sv1 + + checkDsoRulesHostedSv app hostedSv + (mkHostedSvInfo hostedSvName round updatedHostedSvWeight sv1SvInfo.participantId sv1) + + -- hostedSv removes (ClearBeneficiaries) SvRewardBeneficiaries contract where newSvOperator is the SV operator + -- Note: it's not needed as they were cleared in the one step above. + -- In case there were beneficaries, they would need to be recreated with the new SV operator + + -- newSvOperator cannot create coupons for the migrated hosted SV + [(rulesCid, _)] <- query @DsoRules app.dso + rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app newSvOperator + void $ submit (actAs newSvOperator <> readAs dso) $ + exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with + openRoundCid = roundCid + rewardStatesWithBeneficiaries + sv = newSvOperator + + checkSvCouponBeneficariesBySv hostedSv round [] + + -- sv1 as the new SV operator creates SV coupons + [(rulesCid, _)] <- query @DsoRules app.dso + rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app sv1 + void $ submit (actAs sv1 <> readAs dso) $ + exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with + openRoundCid = roundCid + rewardStatesWithBeneficiaries + sv = sv1 + + checkSvCouponBeneficariesBySv hostedSv round hostedSvBeneficiariesAfterClear + + + -- Offboarding an SV node + -------------------------- + -- Assuming that all hosted SV operated by the SV node has been migrated to a new SV node + + sv4SvInfo <- getSvInfoByParty app sv4 + + initiateAndAcceptVote app [sv1, sv2, sv3, sv4, newSvOperator] $ + ARC_DsoRules with + dsoAction = SRARC_OffboardSv DsoRules_OffboardSv with + sv = sv4 + + [(_, rules)] <- query @DsoRules app.dso + Map.lookup sv4 rules.svs === None + Map.lookup sv4 rules.offboardedSvs === Some + OffboardedSvInfo with + name = sv4SvInfo.name + participantId = sv4SvInfo.participantId + + pure () + +test_SvWeightsMigration: Script () +test_SvWeightsMigration = do + -- Setup --------- (app, dso, (sv1, sv2, sv3, sv4)) <- initMainNet @@ -78,8 +355,7 @@ test_SvWeightsFullyOnledger = do runNextIssuance app [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso - let totalWeight beneficiaries = sum (map snd beneficiaries) - sv1Beneficiaries = beneficiariesFromLegacy [sv1, sv1_b] + let sv1Beneficiaries = beneficiariesFromLegacy [sv1, sv1_b] hostedSvBeneficiaries = beneficiariesFromLegacy [hostedSv, hostedSv_b] ghostSvBeneficiaries = beneficiariesFromLegacy [ghostSv] @@ -131,10 +407,17 @@ test_SvWeightsFullyOnledger = do svParty = hostedSv - -- Creating SV coupons using the new DsoRules_ReceiveSvRewardCoupon_V2 - ----------------------------------------------------------------------- + -- Creating SV coupons + ----------------------- - -- Note: The legacy DsoRules_ReceiveSvRewardCoupon flow for sv1 as SV operator is invalidated once the migration becomes effective. + -- Note: The legacy DsoRules_ReceiveSvRewardCoupon flow for sv1 as SV operator is invalidated + -- once the migration becomes effective (There's no SvRewardState contract for sv1, and sv1's weight as SV operator was set to 0) + [] <- queryFilter @SvRewardState dso (\s -> s.svName == sv1SvInfo.name) + -- void $ submit (actAs newSvOperator <> readAs dso) $ exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon with + -- openRoundCid = roundCid + -- rewardStateCid = undefined -- No contract + -- beneficiaries = hostedSvBeneficiaries + -- sv = newSvOperator [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app sv1 @@ -148,189 +431,18 @@ test_SvWeightsFullyOnledger = do checkSvCouponBeneficariesBySv hostedSv round hostedSvBeneficiaries checkSvCouponBeneficariesBySv ghostSv round ghostSvBeneficiaries - - -- Updating SV weight + Updating beneficaries weight - ----------------------------------------------------- - -- Reduce sv1's total reward weight. In this test, the reduction is applied to - -- sv1_b's beneficiary share so the beneficiary distribution matches the new total. - - runNextIssuance app - - let sv1BeneficiariesMap = Map.fromList sv1Beneficiaries - sv1BWeightDelta = -10 - updatedSv1BWeight = unsafeLookup sv1_b sv1BeneficiariesMap + sv1BWeightDelta - updatedSv1Beneficiaries = - [ (sv1, unsafeLookup sv1 sv1BeneficiariesMap) - , (sv1_b, updatedSv1BWeight) - ] - updatedSv1Weight = totalWeight updatedSv1Beneficiaries - updatedSv1ExtraBeneficiaries = [(sv1_b, updatedSv1BWeight)] - - -- Update sv1 weight (this includes the weight shared with sv1_b) - initiateAndAcceptVote app [sv1, sv2, sv3, sv4] $ - ARC_DsoRules with - dsoAction = SRARC_UpdateSvRewardWeight_V2 DsoRules_UpdateSvRewardWeight_V2 with - svParty = sv1 - newRewardWeight = updatedSv1Weight - - -- Update sv1's beneficaries weight - [(rulesCid, _)] <- query @DsoRules app.dso - [(sv1SvRewardBeneficiariesCid, _)] <- - queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == sv1 && b.svOperator == sv1) - void $ submit (actAs sv1 <> readAs dso) $ - exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with - action = SetBeneficiaries with - optCid = Some sv1SvRewardBeneficiariesCid - beneficiaries = updatedSv1ExtraBeneficiaries - svParty = sv1 - - -- Create SV coupons - [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso - rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app sv1 - void $ submit (actAs sv1 <> readAs dso) $ - exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with - openRoundCid = roundCid - rewardStatesWithBeneficiaries - sv = sv1 - - checkSvCouponBeneficariesBySv sv1 round updatedSv1Beneficiaries - checkSvCouponBeneficariesBySv hostedSv round hostedSvBeneficiaries - checkSvCouponBeneficariesBySv ghostSv round ghostSvBeneficiaries - - - -- Removing SV beneficiaries - ----------------------------- - -- Remove sv1_b's weight, so sv1 receives all the SV coupons - - runNextIssuance app - - let sv1BeneficiariesAfterClear = [(sv1, updatedSv1Weight)] - - -- Remove sv1_b's weight - [(sv1SvRewardBeneficiariesCid, _)] <- - queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == sv1 && b.svOperator == sv1) - void $ submit (actAs sv1 <> readAs dso) $ - exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with - action = ClearBeneficiaries with - cid = sv1SvRewardBeneficiariesCid - svParty = sv1 - - -- Create SV coupons - [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso - rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app sv1 - void $ submit (actAs sv1 <> readAs dso) $ - exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with - openRoundCid = roundCid - rewardStatesWithBeneficiaries - sv = sv1 - - checkSvCouponBeneficariesBySv sv1 round sv1BeneficiariesAfterClear - checkSvCouponBeneficariesBySv hostedSv round hostedSvBeneficiaries - checkSvCouponBeneficariesBySv ghostSv round ghostSvBeneficiaries - - - -- Onboarding an SV node - ------------------------- - - let newSvOperatorAlias = "new-SV-operator" - newSvNodeName = newSvOperatorAlias <> "-node-1" - newSvNodeParticipantId = newSvNodeName <> "-participant-id" - newSvOperator <- allocateParty newSvOperatorAlias - - initiateAndAcceptVote app [sv1, sv2, sv3, sv4] $ - ARC_DsoRules with - dsoAction = SRARC_AddSvNode DsoRules_AddSvNode with - newSvOperatorParty = newSvOperator - newSvNodeName - newSvNodeParticipantId - - - -- Offboarding/Onboarding a hosted SV - -------------------------------------- - -- Migrate hostedSv from sv1 to newSvOperator - -- To do that, we need to offboard hostedSv and then onboard it again under newSvOperator - - runNextIssuance app - - -- Offboard hostedSv - initiateAndAcceptVote app [sv1, sv2, sv3, sv4, newSvOperator] $ - ARC_DsoRules with - dsoAction = SRARC_OffboardHostedSv DsoRules_OffboardHostedSv with - svParty = hostedSv - - -- Add hostedSv with newSvOperator as SV operator - newSvOperatorSvInfo <- getSvInfoByParty app newSvOperator - [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso - initiateAndAcceptVote app [sv1, sv2, sv3, sv4, newSvOperator] $ - ARC_DsoRules with - dsoAction = SRARC_AddHostedSv DsoRules_AddHostedSv with - newSvParty = hostedSv - newSvName = hostedSvName - newSvRewardWeight = hostedSvWeight - newSvParticipantId = newSvOperatorSvInfo.participantId - joinedAsOfRound = round.round - svOperator = newSvOperator - - -- hostedSv removes (ClearBeneficiaries) SvRewardBeneficiaries contract where sv1 is the SV operator - [(rulesCid, _)] <- query @DsoRules app.dso - [(hostedSvSvRewardBeneficiariesCid, _)] <- - queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == hostedSv && b.svOperator == sv1) - void $ submit (actAs hostedSv <> readAs dso) $ - exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with - action = ClearBeneficiaries with - cid = hostedSvSvRewardBeneficiariesCid - svParty = hostedSv - - -- hostedSv recreates SvRewardBeneficiaries contract with newSvOperator as SV operator - void $ submit (actAs hostedSv <> readAs dso) $ exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with - action = SetBeneficiaries with - optCid = None - beneficiaries = [(hostedSv_b, unsafeLookup hostedSv_b sv1LegacyBeneficiariesMap)] - svParty = hostedSv - - -- sv1 as SV operator creates SV coupons - [(rulesCid, _)] <- query @DsoRules app.dso - rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app sv1 - void $ submit (actAs sv1 <> readAs dso) $ - exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with - openRoundCid = roundCid - rewardStatesWithBeneficiaries - sv = sv1 - - checkNoSvCouponsForSvInRound hostedSv round - checkSvCouponBeneficariesBySv sv1 round sv1BeneficiariesAfterClear - checkSvCouponBeneficariesBySv ghostSv round ghostSvBeneficiaries - - -- newSvOperator as SV operator creates SV coupons - rewardStatesWithBeneficiaries <- getRewardStatesWithBeneficiariesBySvOperator app newSvOperator - void $ submit (actAs newSvOperator <> readAs dso) $ - exerciseCmd rulesCid DsoRules_ReceiveSvRewardCoupon_V2 with - openRoundCid = roundCid - rewardStatesWithBeneficiaries - sv = newSvOperator - - checkSvCouponBeneficariesBySv hostedSv round hostedSvBeneficiaries - - - -- Offboarding an SV node - -------------------------- - -- Assuming that all hosted SV operated by the SV node has been migrated to a new SV node - - initiateAndAcceptVote app [sv1, sv2, sv3, newSvOperator] $ - ARC_DsoRules with - dsoAction = SRARC_OffboardSv DsoRules_OffboardSv with - sv = sv4 - pure () - -- Helpers ----------- getHostedSvsMap : DsoRules -> Map.Map Party HostedSvInfo getHostedSvsMap rules = fromOptional Map.empty rules.hostedSvs +getOffboardedHostedSvsMap : DsoRules -> Map.Map Party OffboardedSvInfo +getOffboardedHostedSvsMap rules = fromOptional Map.empty rules.offboardedHostedSvs + checkSvCouponBeneficariesBySv : Party -> OpenMiningRound -> [(Party, Int)] -> Script () checkSvCouponBeneficariesBySv sv round expected = do (_, coupons) <- unzip <$> queryFilter @SvRewardCoupon sv (\co -> co.round == round.round && co.sv == sv) @@ -353,10 +465,19 @@ checkDsoRulesHostedSv app hostedSv expected = do hostedSvInfo <- getHostedSvInfoByParty app hostedSv hostedSvInfo === expected +checkDsoRulesSv : AmuletApp -> Party -> SvInfo -> Script () +checkDsoRulesSv app sv expected = do + svInfo <- getSvInfoByParty app sv + svInfo === expected + mkHostedSvInfo : Text -> OpenMiningRound -> Int -> Text -> Party -> HostedSvInfo mkHostedSvInfo name joinedAsOfRound svRewardWeight participantId svOperator = HostedSvInfo with joinedAsOfRound = joinedAsOfRound.round; .. +mkSvInfo : Text -> OpenMiningRound -> Int -> Text -> Party -> SvInfo +mkSvInfo name joinedAsOfRound svRewardWeight participantId svOperator = + SvInfo with joinedAsOfRound = joinedAsOfRound.round; .. + mkSvMigrationData : Party -> Text -> Text -> Int -> SvMigrationData mkSvMigrationData svParty svName svParticipantId svRewardWeight = SvMigrationData with .. @@ -394,3 +515,6 @@ getRewardStatesWithBeneficiariesBySvOperator app svOperator = do [cid] -> Some cid _ -> fail "Expected at most one SvRewardBeneficiaries contract" ) + +totalWeight : [(Party, Int)] -> Int +totalWeight beneficiaries = sum (map snd beneficiaries) From 9552ebcde30fba4a3ff5540cba646c63d6721a80 Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Wed, 27 May 2026 14:39:18 +0000 Subject: [PATCH 10/19] document SvMigrationData Signed-off-by: Jose Velasco --- daml/splice-dso-governance/daml/Splice/DsoRules.daml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 0a8b9e076c..4f61ab616e 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -163,6 +163,12 @@ data DsoSummary = DsoSummary with -- ^ The number of votes required for considering a confirmation, or a request for a vote deriving (Eq, Show) +-- | Migration data for one SV whose reward weight is being moved from the +-- legacy SV operator weight model into `hostedSvs`. +-- +-- Each value represents one SV operated by the SV operator performing the +-- migration. The corresponding `svRewardWeight` becomes the on-ledger hosted SV +-- weight for that SV under the migrating SV operator. data SvMigrationData = SvMigrationData with svParty : Party -- ^ The SV party. svName : Text -- ^ Human-readable name; must be unique. From d09430cd4e33a712b38dca102f03db7c9325b62b Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Wed, 27 May 2026 14:45:55 +0000 Subject: [PATCH 11/19] adjust signatories and observers in SvRewardBeneficiaries template Signed-off-by: Jose Velasco --- daml/splice-dso-governance/daml/Splice/DsoRules.daml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 4f61ab616e..692de629fc 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -572,8 +572,8 @@ template SvRewardBeneficiaries && all (> 0) (map snd beneficiaries) && notElem sv (map fst beneficiaries) - signatory dso, sv - observer svOperator + signatory dso + observer sv -- | Action to apply to the SV reward beneficiary distribution. data SetSvRewardBeneficiariesAction From 2c6571c418e90e3a09a7cf6c18bb816d4143067d Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Wed, 27 May 2026 14:48:40 +0000 Subject: [PATCH 12/19] cleanup Signed-off-by: Jose Velasco --- .../daml/Splice/Scripts/TestSvWeights.daml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml index 14bda2b515..4a4200a336 100644 --- a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml @@ -46,7 +46,7 @@ test_SvWeightsFullyOnledger = do -- Onboarding an SV node ------------------------- - [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso + [(_, round), _, _] <- getActiveOpenRoundsSorted app.dso initiateAndAcceptVote app [sv1, sv2, sv3, sv4] $ ARC_DsoRules with dsoAction = SRARC_AddSvNode DsoRules_AddSvNode with @@ -54,7 +54,7 @@ test_SvWeightsFullyOnledger = do newSvNodeName newSvNodeParticipantId - checkDsoRulesSv app newSvOperator (mkSvInfo newSvNodeName round 0 newSvNodeParticipantId newSvOperator) + checkDsoRulesSv app newSvOperator (mkSvInfo newSvNodeName round 0 newSvNodeParticipantId) -- Onboarding a hosted SV @@ -474,8 +474,8 @@ mkHostedSvInfo : Text -> OpenMiningRound -> Int -> Text -> Party -> HostedSvInfo mkHostedSvInfo name joinedAsOfRound svRewardWeight participantId svOperator = HostedSvInfo with joinedAsOfRound = joinedAsOfRound.round; .. -mkSvInfo : Text -> OpenMiningRound -> Int -> Text -> Party -> SvInfo -mkSvInfo name joinedAsOfRound svRewardWeight participantId svOperator = +mkSvInfo : Text -> OpenMiningRound -> Int -> Text -> SvInfo +mkSvInfo name joinedAsOfRound svRewardWeight participantId = SvInfo with joinedAsOfRound = joinedAsOfRound.round; .. mkSvMigrationData : Party -> Text -> Text -> Int -> SvMigrationData From 5013aefb025a5f558f9a7b695c19dbc8c07e6614 Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Wed, 27 May 2026 16:52:42 +0000 Subject: [PATCH 13/19] add a note about the three kind of parties in DsoRules Signed-off-by: Jose Velasco --- .../splice-dso-governance/daml/Splice/DsoRules.daml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 692de629fc..4d96e82c8c 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -594,6 +594,19 @@ data SetSvRewardBeneficiariesAction deriving (Eq, Show) +-- This template distinguishes between three kinds of parties: +-- +-- * SV node operators: parties that operate an SV node. These are tracked in +-- `svs` and contain node/operator metadata. An SV node operator does not +-- itself define the reward weight. +-- +-- * Hosted SVs: first-class SVs that have an on-ledger SV reward weight. These +-- are tracked in `hostedSvs`. If an SV node operator party is also a hosted +-- SV, it is also included in `hostedSvs` with the corresponding weight. +-- +-- * Beneficiaries: parties that receive a portion of an SV's rewards. These are +-- configured separately through `SvRewardBeneficiaries` and are not managed as +-- part of SV governance. template DsoRules with dso : Party epoch : Int From 927d5147d69401e5e7990d59e9f68ed992e749bd Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Thu, 28 May 2026 10:13:56 +0000 Subject: [PATCH 14/19] remove unneeded svParticipantId for hosted svs Signed-off-by: Jose Velasco --- .../daml/Splice/Scripts/TestSvWeights.daml | 36 ++++++++----------- .../daml/Splice/DsoRules.daml | 18 +++++----- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml index 4a4200a336..c3f6bf5efd 100644 --- a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml @@ -61,19 +61,17 @@ test_SvWeightsFullyOnledger = do -------------------------- -- Add hostedSv with newSvOperator as SV operator - newSvOperatorSvInfo <- getSvInfoByParty app newSvOperator initiateAndAcceptVote app [sv1, sv2, sv3, sv4, newSvOperator] $ ARC_DsoRules with dsoAction = SRARC_AddHostedSv DsoRules_AddHostedSv with newSvParty = hostedSv newSvName = hostedSvName newSvRewardWeight = hostedSvWeight - newSvParticipantId = newSvOperatorSvInfo.participantId joinedAsOfRound = round.round svOperator = newSvOperator checkDsoRulesHostedSv app hostedSv - (mkHostedSvInfo hostedSvName round hostedSvWeight newSvOperatorSvInfo.participantId newSvOperator) + (mkHostedSvInfo hostedSvName round hostedSvWeight newSvOperator) -- Creating beneficiary's weight @@ -155,7 +153,7 @@ test_SvWeightsFullyOnledger = do newRewardWeight = updatedHostedSvWeight checkDsoRulesHostedSv app hostedSv - (mkHostedSvInfo hostedSvName round updatedHostedSvWeight newSvOperatorSvInfo.participantId newSvOperator) + (mkHostedSvInfo hostedSvName round updatedHostedSvWeight newSvOperator) -- Update hostedSv_b's beneficaries weight [(rulesCid, _)] <- query @DsoRules app.dso @@ -231,24 +229,20 @@ test_SvWeightsFullyOnledger = do [(_, rules)] <- query @DsoRules app.dso Map.lookup hostedSv (getHostedSvsMap rules) === None Map.lookup hostedSv (getOffboardedHostedSvsMap rules) === Some - OffboardedSvInfo with - name = hostedSvSvInfo.name - participantId = hostedSvSvInfo.participantId + OffboardedHostedSvInfo with name = hostedSvSvInfo.name -- Add hostedSv with sv1 as SV operator - sv1SvInfo <- getSvInfoByParty app sv1 initiateAndAcceptVote app [sv1, sv2, sv3, sv4, newSvOperator] $ ARC_DsoRules with dsoAction = SRARC_AddHostedSv DsoRules_AddHostedSv with newSvParty = hostedSv newSvName = hostedSvName newSvRewardWeight = updatedHostedSvWeight - newSvParticipantId = sv1SvInfo.participantId joinedAsOfRound = round.round svOperator = sv1 checkDsoRulesHostedSv app hostedSv - (mkHostedSvInfo hostedSvName round updatedHostedSvWeight sv1SvInfo.participantId sv1) + (mkHostedSvInfo hostedSvName round updatedHostedSvWeight sv1) -- hostedSv removes (ClearBeneficiaries) SvRewardBeneficiaries contract where newSvOperator is the SV operator -- Note: it's not needed as they were cleared in the one step above. @@ -365,9 +359,9 @@ test_SvWeightsMigration = do sv1AsHostedSvName = sv1SvInfo.name <> "-hostedSV" svsMigrationData = - [ mkSvMigrationData sv1 sv1AsHostedSvName sv1SvInfo.participantId sv1Weight -- sv1 itself is migrated as hosted SV - , mkSvMigrationData hostedSv hostedSvName sv1SvInfo.participantId hostedSvWeight - , mkSvMigrationData ghostSv ghostSvName sv1SvInfo.participantId ghostSvWeight + [ mkSvMigrationData sv1 sv1AsHostedSvName sv1Weight -- sv1 itself is migrated as hosted SV + , mkSvMigrationData hostedSv hostedSvName hostedSvWeight + , mkSvMigrationData ghostSv ghostSvName ghostSvWeight ] -- Migrate sv1, hostedSv and ghostSv @@ -386,9 +380,9 @@ test_SvWeightsMigration = do -- Check hosted SVs [(rulesCid, rules)] <- query @DsoRules app.dso Map.size (getHostedSvsMap rules) === 3 - checkDsoRulesHostedSv app sv1 (mkHostedSvInfo sv1AsHostedSvName round sv1Weight sv1SvInfo.participantId sv1) - checkDsoRulesHostedSv app hostedSv (mkHostedSvInfo hostedSvName round hostedSvWeight sv1SvInfo.participantId sv1) - checkDsoRulesHostedSv app ghostSv (mkHostedSvInfo ghostSvName round ghostSvWeight sv1SvInfo.participantId sv1) + checkDsoRulesHostedSv app sv1 (mkHostedSvInfo sv1AsHostedSvName round sv1Weight sv1) + checkDsoRulesHostedSv app hostedSv (mkHostedSvInfo hostedSvName round hostedSvWeight sv1) + checkDsoRulesHostedSv app ghostSv (mkHostedSvInfo ghostSvName round ghostSvWeight sv1) -- Check hosted SVs SvRewardState contracts checkSvRewardStateExists app sv1AsHostedSvName checkSvRewardStateExists app hostedSvName @@ -440,7 +434,7 @@ test_SvWeightsMigration = do getHostedSvsMap : DsoRules -> Map.Map Party HostedSvInfo getHostedSvsMap rules = fromOptional Map.empty rules.hostedSvs -getOffboardedHostedSvsMap : DsoRules -> Map.Map Party OffboardedSvInfo +getOffboardedHostedSvsMap : DsoRules -> Map.Map Party OffboardedHostedSvInfo getOffboardedHostedSvsMap rules = fromOptional Map.empty rules.offboardedHostedSvs checkSvCouponBeneficariesBySv : Party -> OpenMiningRound -> [(Party, Int)] -> Script () @@ -470,16 +464,16 @@ checkDsoRulesSv app sv expected = do svInfo <- getSvInfoByParty app sv svInfo === expected -mkHostedSvInfo : Text -> OpenMiningRound -> Int -> Text -> Party -> HostedSvInfo -mkHostedSvInfo name joinedAsOfRound svRewardWeight participantId svOperator = +mkHostedSvInfo : Text -> OpenMiningRound -> Int -> Party -> HostedSvInfo +mkHostedSvInfo name joinedAsOfRound svRewardWeight svOperator = HostedSvInfo with joinedAsOfRound = joinedAsOfRound.round; .. mkSvInfo : Text -> OpenMiningRound -> Int -> Text -> SvInfo mkSvInfo name joinedAsOfRound svRewardWeight participantId = SvInfo with joinedAsOfRound = joinedAsOfRound.round; .. -mkSvMigrationData : Party -> Text -> Text -> Int -> SvMigrationData -mkSvMigrationData svParty svName svParticipantId svRewardWeight = +mkSvMigrationData : Party -> Text -> Int -> SvMigrationData +mkSvMigrationData svParty svName svRewardWeight = SvMigrationData with .. unsafeLookup : (Ord k, Show k) => k -> Map.Map k v -> v diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 4d96e82c8c..3fd5729e84 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -145,7 +145,6 @@ data HostedSvInfo = HostedSvInfo with name : Text -- ^ Human-readable name; must be unique. joinedAsOfRound : Round -- ^ Round in which the SV joined svRewardWeight : Int -- ^ Weight of the SV in the SV reward distribution. - participantId : Text -- ^ Participant ID of the SV, stored here as PartyToParticipant mappings are tracked via state on the DsoRules + SvOnboardingConfirmed contracts. svOperator : Party -- ^ The SV operator party responsible for creating reward coupons for the SV. deriving (Eq, Show) @@ -155,6 +154,11 @@ data OffboardedSvInfo = OffboardedSvInfo with participantId : Text -- ^ Participant ID of the offboarded SV. deriving (Eq, Show) +-- | Information about offboarded hosted svs +data OffboardedHostedSvInfo = OffboardedHostedSvInfo with + name : Text -- ^ Human-readable name; must be unique. + deriving (Eq, Show) + data DsoSummary = DsoSummary with dsoDelegate : Party -- ^ __Deprecated__ in favor of delegateless automation. @@ -172,7 +176,6 @@ data DsoSummary = DsoSummary with data SvMigrationData = SvMigrationData with svParty : Party -- ^ The SV party. svName : Text -- ^ Human-readable name; must be unique. - svParticipantId : Text -- ^ Participant ID of the SV. svRewardWeight : Int -- ^ Weight of the SV in the SV reward distribution. deriving (Eq, Show) @@ -618,7 +621,7 @@ template DsoRules with initialTrafficState: Map.Map Text TrafficState -- ^ Map from participant/mediator ID to its traffic state at the time of synchronizer bootstrapping. Used for testing, empty in prod. isDevNet : Bool hostedSvs : Optional (Map.Map Party HostedSvInfo) -- ^ List of hosted SVs. - offboardedHostedSvs : Optional (Map.Map Party OffboardedSvInfo) + offboardedHostedSvs : Optional (Map.Map Party OffboardedHostedSvInfo) where ensure config.numUnclaimedRewardsThreshold > 0 @@ -693,7 +696,6 @@ template DsoRules with newSvParty : Party newSvName : Text newSvRewardWeight : Int - newSvParticipantId : Text joinedAsOfRound : Round svOperator : Party controller dso @@ -717,7 +719,6 @@ template DsoRules with name = newSvName joinedAsOfRound svRewardWeight = newSvRewardWeight - participantId = newSvParticipantId svOperator updatedHostedSvs = Map.insert newSvParty hostedSvInfo (fromOptional Map.empty hostedSvs) @@ -1179,12 +1180,10 @@ template DsoRules with None -> fail "SV party is not registered" Some hostedSvInfo -> do let newHostedSvs = Map.delete svParty hostedSvsMap - offboardedSvInfo = OffboardedSvInfo with - name = hostedSvInfo.name - participantId = hostedSvInfo.participantId + offboardedHostedSvInfo = OffboardedHostedSvInfo with name = hostedSvInfo.name newDsoRules <- create this with hostedSvs = Some newHostedSvs - offboardedHostedSvs = Some $ Map.insert svParty offboardedSvInfo offboardedHostedSvsMap + offboardedHostedSvs = Some $ Map.insert svParty offboardedHostedSvInfo offboardedHostedSvsMap pure DsoRules_RemoveHostedSvResult with newDsoRules -- | Sets or clears the SV reward beneficiary distribution for an SV. @@ -1288,7 +1287,6 @@ template DsoRules with newSvParty = svMigrationData.svParty newSvName = svMigrationData.svName newSvRewardWeight = svMigrationData.svRewardWeight - newSvParticipantId = svMigrationData.svParticipantId joinedAsOfRound = openRound.round svOperator = sv pure newDsoRules From 15df7001448fa078c273bf620f131c6e98e41370 Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Thu, 28 May 2026 12:30:13 +0000 Subject: [PATCH 15/19] make SvRewardBeneficiaries contract mandatory + add the creation of SvRewardBeneficiaries with empty beneficiaries to the onboard flow of a hosted SV Signed-off-by: Jose Velasco --- .../daml/Splice/Scripts/TestSvWeights.daml | 120 +++++++++++------- .../daml/Splice/DsoRules.daml | 118 +++++++---------- 2 files changed, 121 insertions(+), 117 deletions(-) diff --git a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml index c3f6bf5efd..c9c3ac8248 100644 --- a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml @@ -60,6 +60,7 @@ test_SvWeightsFullyOnledger = do -- Onboarding a hosted SV -------------------------- -- Add hostedSv with newSvOperator as SV operator + -- Note: The onboarding flow creates a SvRewardBeneficiaries contract with no beneficiaries initiateAndAcceptVote app [sv1, sv2, sv3, sv4, newSvOperator] $ ARC_DsoRules with @@ -73,26 +74,30 @@ test_SvWeightsFullyOnledger = do checkDsoRulesHostedSv app hostedSv (mkHostedSvInfo hostedSvName round hostedSvWeight newSvOperator) + checkSvRewardBeneficiaries app hostedSv newSvOperator + SvRewardBeneficiaries with + dso + sv = hostedSv + svOperator = newSvOperator + beneficiaries = [] - -- Creating beneficiary's weight - --------------------------------- + -- Replace the default beneficiaries let hostedSvExtraBeneficiaries = [(hostedSv_b, unsafeLookup hostedSv_b hostedSvBeneficiariesMap)] + hostedSvSvRewardBeneficiariesCid <- getSvRewardBeneficiariesCid app hostedSv newSvOperator [(rulesCid, _)] <- query @DsoRules app.dso void $ submit (actAs hostedSv <> readAs dso) $ exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with - action = SetBeneficiaries with - optCid = None - beneficiaries = hostedSvExtraBeneficiaries + svRewardBeneficiariesCid = hostedSvSvRewardBeneficiariesCid + newBeneficiaries = hostedSvExtraBeneficiaries svParty = hostedSv - [(hostedSvSvRewardBeneficiariesCid, hostedSvSvRewardBeneficiaries)] <- - queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == hostedSv && b.svOperator == newSvOperator) - hostedSvSvRewardBeneficiaries === SvRewardBeneficiaries with - dso - sv = hostedSv - svOperator = newSvOperator - beneficiaries = hostedSvExtraBeneficiaries + checkSvRewardBeneficiaries app hostedSv newSvOperator + SvRewardBeneficiaries with + dso + sv = hostedSv + svOperator = newSvOperator + beneficiaries = hostedSvExtraBeneficiaries -- Creating SV coupons @@ -156,21 +161,20 @@ test_SvWeightsFullyOnledger = do (mkHostedSvInfo hostedSvName round updatedHostedSvWeight newSvOperator) -- Update hostedSv_b's beneficaries weight + hostedSvSvRewardBeneficiariesCid <- getSvRewardBeneficiariesCid app hostedSv newSvOperator [(rulesCid, _)] <- query @DsoRules app.dso void $ submit (actAs hostedSv <> readAs dso) $ exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with - action = SetBeneficiaries with - optCid = Some hostedSvSvRewardBeneficiariesCid - beneficiaries = updatedHostedSvExtraBeneficiaries + svRewardBeneficiariesCid = hostedSvSvRewardBeneficiariesCid + newBeneficiaries = updatedHostedSvExtraBeneficiaries svParty = hostedSv - [(hostedSvSvRewardBeneficiariesCid, hostedSvSvRewardBeneficiaries)] <- - queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == hostedSv && b.svOperator == newSvOperator) - hostedSvSvRewardBeneficiaries === SvRewardBeneficiaries with - dso - sv = hostedSv - svOperator = newSvOperator - beneficiaries = updatedHostedSvExtraBeneficiaries + checkSvRewardBeneficiaries app hostedSv newSvOperator + SvRewardBeneficiaries with + dso + sv = hostedSv + svOperator = newSvOperator + beneficiaries = updatedHostedSvExtraBeneficiaries -- Create SV coupons [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso @@ -184,19 +188,20 @@ test_SvWeightsFullyOnledger = do checkSvCouponBeneficariesBySv hostedSv round updatedHostedSvBeneficiaries - -- Removing SV beneficiaries + -- Clearing SV beneficiaries ----------------------------- - -- Remove hostedSv_b's weight, so hostedSv receives all the SV coupons + -- Remove hostedSv_b from beneficiaries, so hostedSv receives all the SV coupons runNextIssuance app let hostedSvBeneficiariesAfterClear = [(hostedSv, updatedHostedSvWeight)] -- Remove hostedSv_b's weight + hostedSvSvRewardBeneficiariesCid <- getSvRewardBeneficiariesCid app hostedSv newSvOperator void $ submit (actAs hostedSv <> readAs dso) $ exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with - action = ClearBeneficiaries with - cid = hostedSvSvRewardBeneficiariesCid + svRewardBeneficiariesCid = hostedSvSvRewardBeneficiariesCid + newBeneficiaries = [] svParty = hostedSv -- Create SV coupons @@ -211,11 +216,13 @@ test_SvWeightsFullyOnledger = do checkSvCouponBeneficariesBySv hostedSv round hostedSvBeneficiariesAfterClear - -- Updating the SV operator of a hosted SV - ------------------------------------------- - -- Migrate hostedSv from snewSvOperatorv1 to sv1 + -- Updating the SV operator of a hosted SV (Offboarding/Onboarding) + -------------------------------------------------------------------- + -- Migrate hostedSv from newSvOperator to sv1 -- To do that, we need to offboard hostedSv and then onboard it again under the new SV operator + -- Offboarding -- + hostedSvSvInfo <- getHostedSvInfoByParty app hostedSv runNextIssuance app [(roundCid, round), _, _] <- getActiveOpenRoundsSorted app.dso @@ -231,6 +238,15 @@ test_SvWeightsFullyOnledger = do Map.lookup hostedSv (getOffboardedHostedSvsMap rules) === Some OffboardedHostedSvInfo with name = hostedSvSvInfo.name + -- Archive the SvRewardBeneficiaries contract as part of the offboarding flow. + -- The archival will be run by an automation in the SV backend. + hostedSvSvRewardBeneficiariesCid <- getSvRewardBeneficiariesCid app hostedSv newSvOperator + void $ submit (actAs dso) $ + archiveCmd hostedSvSvRewardBeneficiariesCid + + + -- Onboarding -- + -- Add hostedSv with sv1 as SV operator initiateAndAcceptVote app [sv1, sv2, sv3, sv4, newSvOperator] $ ARC_DsoRules with @@ -243,10 +259,12 @@ test_SvWeightsFullyOnledger = do checkDsoRulesHostedSv app hostedSv (mkHostedSvInfo hostedSvName round updatedHostedSvWeight sv1) - - -- hostedSv removes (ClearBeneficiaries) SvRewardBeneficiaries contract where newSvOperator is the SV operator - -- Note: it's not needed as they were cleared in the one step above. - -- In case there were beneficaries, they would need to be recreated with the new SV operator + checkSvRewardBeneficiaries app hostedSv sv1 + SvRewardBeneficiaries with + dso + sv = hostedSv + svOperator = sv1 + beneficiaries = [] -- newSvOperator cannot create coupons for the migrated hosted SV [(rulesCid, _)] <- query @DsoRules app.dso @@ -389,15 +407,15 @@ test_SvWeightsMigration = do checkSvRewardStateExists app ghostSvName -- Migrate beneficaries weights + sv1SvRewardBeneficiariesCid <- getSvRewardBeneficiariesCid app sv1 sv1 void $ submit (actAs sv1 <> readAs dso) $ exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with - action = SetBeneficiaries with - optCid = None - beneficiaries = [(sv1_b, unsafeLookup sv1_b sv1LegacyBeneficiariesMap)] + svRewardBeneficiariesCid = sv1SvRewardBeneficiariesCid + newBeneficiaries = [(sv1_b, unsafeLookup sv1_b sv1LegacyBeneficiariesMap)] svParty = sv1 + hostedSvSvRewardBeneficiariesCid <- getSvRewardBeneficiariesCid app hostedSv sv1 void $ submit (actAs hostedSv <> readAs dso) $ exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with - action = SetBeneficiaries with - optCid = None - beneficiaries = [(hostedSv_b, unsafeLookup hostedSv_b sv1LegacyBeneficiariesMap)] + svRewardBeneficiariesCid = hostedSvSvRewardBeneficiariesCid + newBeneficiaries = [(hostedSv_b, unsafeLookup hostedSv_b sv1LegacyBeneficiariesMap)] svParty = hostedSv @@ -490,7 +508,7 @@ checkSvRewardStateExists app svName = do getRewardStatesWithBeneficiariesBySvOperator : AmuletApp -> Party - -> Script [(ContractId SvRewardState, Optional (ContractId SvRewardBeneficiaries))] + -> Script [(ContractId SvRewardState, ContractId SvRewardBeneficiaries)] getRewardStatesWithBeneficiariesBySvOperator app svOperator = do [(_, rules)] <- query @DsoRules app.dso let hostedSvsMap = getHostedSvsMap rules @@ -500,15 +518,21 @@ getRewardStatesWithBeneficiariesBySvOperator app svOperator = do forA hostedSvsOperatedBySvOperator $ \(hostedSvParty, hostedSvInfo) -> do [(rewardStateCid, _)] <- queryFilter @SvRewardState app.dso (\s -> s.svName == hostedSvInfo.name) - svRewardBeneficiariesCids <- map fst <$> + [svRewardBeneficiariesCid] <- map fst <$> queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == hostedSvParty && b.svOperator == svOperator) - pure - ( rewardStateCid - , case svRewardBeneficiariesCids of - [] -> None - [cid] -> Some cid - _ -> fail "Expected at most one SvRewardBeneficiaries contract" - ) + pure (rewardStateCid, svRewardBeneficiariesCid) totalWeight : [(Party, Int)] -> Int totalWeight beneficiaries = sum (map snd beneficiaries) + +getSvRewardBeneficiariesCid : AmuletApp -> Party -> Party -> Script (ContractId SvRewardBeneficiaries) +getSvRewardBeneficiariesCid app hostedSv svOperator = do + [(hostedSvSvRewardBeneficiariesCid, _)] <- + queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == hostedSv && b.svOperator == svOperator) + pure hostedSvSvRewardBeneficiariesCid + +checkSvRewardBeneficiaries : AmuletApp -> Party -> Party -> SvRewardBeneficiaries -> Script () +checkSvRewardBeneficiaries app hostedSv svOperator expected = do + [(_, hostedSvSvRewardBeneficiaries)] <- + queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == hostedSv && b.svOperator == svOperator) + hostedSvSvRewardBeneficiaries === expected \ No newline at end of file diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 3fd5729e84..8e535da628 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -247,7 +247,7 @@ data DsoRules_RemoveHostedSvResult = DsoRules_RemoveHostedSvResult with newDsoRules : ContractId DsoRules data DsoRules_SetSvRewardBeneficiariesResult = DsoRules_SetSvRewardBeneficiariesResult with - svRewardBeneficiariesCid : Optional (ContractId SvRewardBeneficiaries) + svRewardBeneficiariesCid : ContractId SvRewardBeneficiaries data DsoRules_MigrateHostedSvsResult = DsoRules_MigrateHostedSvsResult with newDsoRules : ContractId DsoRules @@ -545,19 +545,19 @@ data TrafficState = TrafficState with deriving (Eq, Show) --- | Optional on-ledger representation of the SV reward beneficiary distribution for an SV. +-- | On-ledger representation of the SV reward beneficiary distribution for an SV. -- --- When this contract exists for an SV, part of the SV reward coupons for that --- SV may be distributed across the configured beneficiaries according to their --- weights. The total beneficiary weight must be less than or equal to the SV --- weight assigned in 'DsoRules'. +-- This contract is created with an empty beneficiary list as part of the hosted +-- SV onboarding flow. The beneficiary list can later be updated if the SV wants +-- to distribute part of its reward weight to beneficiaries. -- -- The SV itself must not be listed as a beneficiary in this contract. Any -- remaining SV reward weight not assigned to beneficiaries is created for the --- SV itself. +-- SV itself. If the beneficiary list is empty, all SV reward coupons for the SV +-- are created for the SV itself. -- --- When this contract does not exist, all SV reward coupons for the SV are --- created for the SV itself. +-- The total beneficiary weight must be less than or equal to the SV weight +-- assigned in 'DsoRules'. template SvRewardBeneficiaries with dso : Party @@ -570,32 +570,13 @@ template SvRewardBeneficiaries -- reward weights. Each beneficiary party must be unique. where ensure - not (null beneficiaries) - && unique (map fst beneficiaries) + unique (map fst beneficiaries) && all (> 0) (map snd beneficiaries) && notElem sv (map fst beneficiaries) signatory dso observer sv --- | Action to apply to the SV reward beneficiary distribution. -data SetSvRewardBeneficiariesAction - = SetBeneficiaries with - optCid : Optional (ContractId SvRewardBeneficiaries) - -- ^ The existing beneficiary distribution contract, if one exists. - -- 'None' creates a new distribution. 'Some' archives and replaces the - -- existing distribution. - beneficiaries : [(Party, Int)] - -- ^ The full beneficiary distribution to set. The total beneficiary - -- weight must be less than or equal to the SV weight in 'DsoRules'. - -- Any unassigned SV weight is created for the SV itself. - | ClearBeneficiaries with - cid : ContractId SvRewardBeneficiaries - -- ^ The existing beneficiary distribution contract to archive. - -- After clearing, all SV reward coupons for the SV are created for - -- the SV itself. - deriving (Eq, Show) - -- This template distinguishes between three kinds of parties: -- @@ -722,6 +703,13 @@ template DsoRules with svOperator updatedHostedSvs = Map.insert newSvParty hostedSvInfo (fromOptional Map.empty hostedSvs) + -- create a default SvRewardBeneficiaries contract with no beneficiaries + void $ create SvRewardBeneficiaries with + dso + sv = newSvParty + svOperator + beneficiaries = [] + newDsoRules <- create this with hostedSvs = Some updatedHostedSvs pure DsoRules_AddHostedSvResult with newDsoRules @@ -1186,46 +1174,39 @@ template DsoRules with offboardedHostedSvs = Some $ Map.insert svParty offboardedHostedSvInfo offboardedHostedSvsMap pure DsoRules_RemoveHostedSvResult with newDsoRules - -- | Sets or clears the SV reward beneficiary distribution for an SV. + -- | Replaces the SV reward beneficiary distribution for an SV. -- - -- This choice is optional for SVs that want to share their SV rewards with - -- beneficiaries. It creates, updates, or removes the corresponding - -- 'SvRewardBeneficiaries' contract. If no such contract exists, all SV reward - -- coupons for the SV are created for the SV itself. + -- Note: A `SvRewardBeneficiaries` contract is created during hosted SV + -- onboarding. This choice replaces that existing contract with a new one + -- containing `newBeneficiaries`. nonconsuming choice DsoRules_SetSvRewardBeneficiaries : DsoRules_SetSvRewardBeneficiariesResult with - action : SetSvRewardBeneficiariesAction - -- ^ The requested operation to apply. + svRewardBeneficiariesCid : ContractId SvRewardBeneficiaries + -- ^ The existing beneficiary distribution contract to replace. + newBeneficiaries : [(Party, Int)] + -- ^ The full beneficiary distribution to set. svParty : Party -- ^ The SV party whose beneficiary distribution is being managed. controller svParty do - svRewardBeneficiariesCid <- - case Map.lookup svParty (fromOptional Map.empty hostedSvs) of - None -> fail "SV party is not registered" - Some hostedSvInfo -> do - let svWeight = hostedSvInfo.svRewardWeight - assertWeights weights = - require ("Sum of weights is less than SV's weight " <> show svWeight) (sum weights <= svWeight) - case action of - ClearBeneficiaries cid -> do - _ <- fetchAndArchive (ForOwner with dso; owner = svParty) cid - pure None - - SetBeneficiaries {optCid = Some cid; beneficiaries} -> do - svRewardBeneficiaries <- fetchAndArchive (ForOwner with dso; owner = svParty) cid - assertWeights (map snd beneficiaries) - Some <$> create svRewardBeneficiaries with beneficiaries - - SetBeneficiaries {optCid = None; beneficiaries} -> do - assertWeights (map snd beneficiaries) - Some <$> create SvRewardBeneficiaries with - dso - sv = svParty - svOperator = hostedSvInfo.svOperator - beneficiaries - - pure DsoRules_SetSvRewardBeneficiariesResult with svRewardBeneficiariesCid + case Map.lookup svParty (fromOptional Map.empty hostedSvs) of + None -> fail "SV party is not registered" + Some hostedSvInfo -> do + svRewardBeneficiaries <- fetchAndArchive (ForOwner with dso; owner = svParty) svRewardBeneficiariesCid + -- Sanity checks + let svWeight = hostedSvInfo.svRewardWeight + require "New beneficiaries must differ from the existing beneficiaries" $ + newBeneficiaries /= svRewardBeneficiaries.beneficiaries + require ("Sum of beneficiary weights is less than or equal to the SV weight " <> show svWeight) $ + sum (map snd newBeneficiaries) <= svWeight + + svRewardBeneficiariesCid <- create SvRewardBeneficiaries with + dso + sv = svParty + svOperator = hostedSvInfo.svOperator + beneficiaries = newBeneficiaries + + pure DsoRules_SetSvRewardBeneficiariesResult with svRewardBeneficiariesCid -- TODO: Deprecate once all SVs have migrated to `hostedSvs`. -- @@ -1681,7 +1662,7 @@ template DsoRules with -- If a SvRewardBeneficiaries contract is provided, one coupon is issued per -- configured beneficiary. The total beneficiary weight must be less than or -- equal to the SV weight. Any remaining weight is issued to the SV itself. - rewardStatesWithBeneficiaries : [(ContractId SvRewardState, Optional (ContractId SvRewardBeneficiaries))] + rewardStatesWithBeneficiaries : [(ContractId SvRewardState, ContractId SvRewardBeneficiaries)] sv : Party -- ^ The SV operator hosting the SVs. controller sv do @@ -1696,7 +1677,7 @@ template DsoRules with require "RewardState contract ids are unique" (unique $ map fst rewardStatesWithBeneficiaries) svRewardStatesWithSvRewardCoupons <- - forA rewardStatesWithBeneficiaries $ \(rewardStateCid, optSvRewardBeneficiariesCid) -> do + forA rewardStatesWithBeneficiaries $ \(rewardStateCid, svRewardBeneficiariesCid) -> do -- check and update state rewardState <- fetchAndArchive (ForDso with dso) rewardStateCid let state = rewardState.state @@ -1712,11 +1693,10 @@ template DsoRules with (hostedSvInfo.svOperator == sv) let svRewardWeight = hostedSvInfo.svRewardWeight - beneficiaries <- case optSvRewardBeneficiariesCid of - None -> pure [(hostedSv, svRewardWeight)] - Some svRewardBeneficiariesCid -> do - svRewardBeneficiaries <- fetchChecked (ForDso with dso) svRewardBeneficiariesCid - let beneficiaries = svRewardBeneficiaries.beneficiaries + svRewardBeneficiaries <- fetchChecked (ForDso with dso) svRewardBeneficiariesCid + beneficiaries <- case svRewardBeneficiaries.beneficiaries of + [] -> pure [(hostedSv, svRewardWeight)] + beneficiaries -> do require "SvRewardBeneficiaries contract belongs to the SV operator creating the rewards" (svRewardBeneficiaries.svOperator == sv) From b448dff91d48a6848871c1ceb8ad661213e67eb5 Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Thu, 28 May 2026 12:40:22 +0000 Subject: [PATCH 16/19] add doc Signed-off-by: Jose Velasco --- daml/splice-dso-governance/daml/Splice/DsoRules.daml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 8e535da628..d4f29a8683 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -1157,6 +1157,12 @@ template DsoRules with newDsoRules <- create this with hostedSvs = Some newHostedSvs pure DsoRules_UpdateSvRewardWeight_V2Result with newDsoRules + -- | Offboards a hosted SV. + -- + -- Note: This choice does not archive the corresponding `SvRewardBeneficiaries` + -- contract. The offboarding process performs that as a separate step through + -- automation in the SV backend, which collects and archives + -- `SvRewardBeneficiaries` contracts whose `sv` appears in the `offboardedHostedSvs` map. choice DsoRules_OffboardHostedSv : DsoRules_RemoveHostedSvResult with svParty : Party From 755a5bb0b22434acef3c28c52e643542163a6ff3 Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Thu, 28 May 2026 14:01:19 +0000 Subject: [PATCH 17/19] enhance DsoRules_AddHostedSvResult Signed-off-by: Jose Velasco --- .../daml/Splice/DsoRules.daml | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index d4f29a8683..47fe10fb60 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -195,6 +195,8 @@ data DsoRules_AddSvOperatorResult = DsoRules_AddSvOperatorResult with data DsoRules_AddHostedSvResult = DsoRules_AddHostedSvResult with newDsoRules : ContractId DsoRules + svRewardStateCid : Optional (ContractId SvRewardState) + svRewardBeneficiariesCid : ContractId SvRewardBeneficiaries data DsoRules_OffboardSvResult = DsoRules_OffboardSvResult with newDsoRules : ContractId DsoRules @@ -685,15 +687,18 @@ template DsoRules with let isSvOperator = flip Map.member svs require "svOperator is an SV operator" $ isSvOperator svOperator - unless (hostedSvHasBeenOnboardedBefore newSvName this) $ - void $ create SvRewardState with - dso - svName = newSvName - state = RewardState with - lastRoundCollected = Round (joinedAsOfRound.number - 1) - numRoundsMissed = 0 - numRoundsCollected = 0 - numCouponsIssued = 0 + svRewardStateCid <- + if hostedSvHasBeenOnboardedBefore newSvName this + then pure None + else Some <$> + create SvRewardState with + dso + svName = newSvName + state = RewardState with + lastRoundCollected = Round (joinedAsOfRound.number - 1) + numRoundsMissed = 0 + numRoundsCollected = 0 + numCouponsIssued = 0 -- register the new SV in the DsoRules let hostedSvInfo = HostedSvInfo with @@ -704,15 +709,16 @@ template DsoRules with updatedHostedSvs = Map.insert newSvParty hostedSvInfo (fromOptional Map.empty hostedSvs) -- create a default SvRewardBeneficiaries contract with no beneficiaries - void $ create SvRewardBeneficiaries with - dso - sv = newSvParty - svOperator - beneficiaries = [] + svRewardBeneficiariesCid <- + create SvRewardBeneficiaries with + dso + sv = newSvParty + svOperator + beneficiaries = [] newDsoRules <- create this with hostedSvs = Some updatedHostedSvs - pure DsoRules_AddHostedSvResult with newDsoRules + pure DsoRules_AddHostedSvResult with newDsoRules; svRewardStateCid; svRewardBeneficiariesCid -- Update an SV's Status report nonconsuming choice DsoRules_SubmitStatusReport : DsoRules_SubmitStatusReportResult From 68685bd9c048d83b87cf4b11b663f55d4739daf2 Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Thu, 28 May 2026 14:45:06 +0000 Subject: [PATCH 18/19] set beneficaries in the migration choice Signed-off-by: Jose Velasco --- .../daml/Splice/Scripts/TestSvWeights.daml | 23 +++-------- .../daml/Splice/DsoRules.daml | 39 +++++++++++++------ 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml index c9c3ac8248..2194a9a7fd 100644 --- a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml @@ -377,9 +377,9 @@ test_SvWeightsMigration = do sv1AsHostedSvName = sv1SvInfo.name <> "-hostedSV" svsMigrationData = - [ mkSvMigrationData sv1 sv1AsHostedSvName sv1Weight -- sv1 itself is migrated as hosted SV - , mkSvMigrationData hostedSv hostedSvName hostedSvWeight - , mkSvMigrationData ghostSv ghostSvName ghostSvWeight + [ mkSvMigrationData sv1 sv1AsHostedSvName sv1Weight [(sv1_b, unsafeLookup sv1_b sv1LegacyBeneficiariesMap)] -- sv1 itself is migrated as hosted SV + , mkSvMigrationData hostedSv hostedSvName hostedSvWeight [(hostedSv_b, unsafeLookup hostedSv_b sv1LegacyBeneficiariesMap)] + , mkSvMigrationData ghostSv ghostSvName ghostSvWeight [] ] -- Migrate sv1, hostedSv and ghostSv @@ -406,18 +406,6 @@ test_SvWeightsMigration = do checkSvRewardStateExists app hostedSvName checkSvRewardStateExists app ghostSvName - -- Migrate beneficaries weights - sv1SvRewardBeneficiariesCid <- getSvRewardBeneficiariesCid app sv1 sv1 - void $ submit (actAs sv1 <> readAs dso) $ exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with - svRewardBeneficiariesCid = sv1SvRewardBeneficiariesCid - newBeneficiaries = [(sv1_b, unsafeLookup sv1_b sv1LegacyBeneficiariesMap)] - svParty = sv1 - hostedSvSvRewardBeneficiariesCid <- getSvRewardBeneficiariesCid app hostedSv sv1 - void $ submit (actAs hostedSv <> readAs dso) $ exerciseCmd rulesCid DsoRules_SetSvRewardBeneficiaries with - svRewardBeneficiariesCid = hostedSvSvRewardBeneficiariesCid - newBeneficiaries = [(hostedSv_b, unsafeLookup hostedSv_b sv1LegacyBeneficiariesMap)] - svParty = hostedSv - -- Creating SV coupons ----------------------- @@ -490,9 +478,8 @@ mkSvInfo : Text -> OpenMiningRound -> Int -> Text -> SvInfo mkSvInfo name joinedAsOfRound svRewardWeight participantId = SvInfo with joinedAsOfRound = joinedAsOfRound.round; .. -mkSvMigrationData : Party -> Text -> Int -> SvMigrationData -mkSvMigrationData svParty svName svRewardWeight = - SvMigrationData with .. +mkSvMigrationData : Party -> Text -> Int -> [(Party, Int)] -> SvMigrationData +mkSvMigrationData svParty svName svRewardWeight beneficaries = SvMigrationData with .. unsafeLookup : (Ord k, Show k) => k -> Map.Map k v -> v unsafeLookup key' m = diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 47fe10fb60..6725e66d78 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -170,13 +170,14 @@ data DsoSummary = DsoSummary with -- | Migration data for one SV whose reward weight is being moved from the -- legacy SV operator weight model into `hostedSvs`. -- --- Each value represents one SV operated by the SV operator performing the +-- Each value represents one hosted SV operated by the SV operator performing the -- migration. The corresponding `svRewardWeight` becomes the on-ledger hosted SV -- weight for that SV under the migrating SV operator. data SvMigrationData = SvMigrationData with svParty : Party -- ^ The SV party. svName : Text -- ^ Human-readable name; must be unique. svRewardWeight : Int -- ^ Weight of the SV in the SV reward distribution. + beneficaries : [(Party, Int)] deriving (Eq, Show) @@ -1274,16 +1275,32 @@ template DsoRules with -- Archive SvRewardState _ <- fetchAndArchive (ForSv with dso; svName = svInfo.name) rewardStateCid - -- Add hosted SVs - let addHostedSv self' svMigrationData = do - DsoRules_AddHostedSvResult {newDsoRules} <- exercise self' DsoRules_AddHostedSv with - newSvParty = svMigrationData.svParty - newSvName = svMigrationData.svName - newSvRewardWeight = svMigrationData.svRewardWeight - joinedAsOfRound = openRound.round - svOperator = sv - pure newDsoRules - newDsoRules <- foldlA addHostedSv updatedDsoRulesWithLegacyWeightInvalidated svsMigrationData + -- Add hosted SVs and beneficiaries + let addHostedSvAndBeneficiaries self' svMigrationData = do + -- onboard hosted SV + DsoRules_AddHostedSvResult { newDsoRules = newDsoRulesCid; svRewardBeneficiariesCid } <- + exercise self' DsoRules_AddHostedSv with + newSvParty = svMigrationData.svParty + newSvName = svMigrationData.svName + newSvRewardWeight = svMigrationData.svRewardWeight + joinedAsOfRound = openRound.round + svOperator = sv + + -- replace the default SvRewardBeneficiaries if beneficiaries are provided + unless (null svMigrationData.beneficaries) do + -- Note: `DsoRules_SetSvRewardBeneficiaries` cannot be exercised here because + -- it is controlled by the hosted SV, while this choice is controlled by the + -- SV operator. + _ <- fetchAndArchive (ForDso with dso) svRewardBeneficiariesCid + void $ create SvRewardBeneficiaries with + dso + sv = svMigrationData.svParty + svOperator = sv + beneficiaries = svMigrationData.beneficaries + + pure newDsoRulesCid + + newDsoRules <- foldlA addHostedSvAndBeneficiaries updatedDsoRulesWithLegacyWeightInvalidated svsMigrationData pure DsoRules_MigrateHostedSvsResult with newDsoRules From 7e716c6bf963c03eb09ccf0d23bd67e00cd8bd81 Mon Sep 17 00:00:00 2001 From: Jose Velasco Date: Thu, 28 May 2026 15:44:53 +0000 Subject: [PATCH 19/19] doc Signed-off-by: Jose Velasco --- daml/splice-dso-governance/daml/Splice/DsoRules.daml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 6725e66d78..b68f5d1e2c 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -140,7 +140,7 @@ data SvInfo = SvInfo with participantId : Text -- ^ Participant ID of the SV, stored here as PartyToParticipant mappings are tracked via state on the DsoRules + SvOnboardingConfirmed contracts. deriving (Eq, Show) --- | Information about SVs relevant to DSO governance. +-- | Information about hosted SVs relevant to DSO governance. data HostedSvInfo = HostedSvInfo with name : Text -- ^ Human-readable name; must be unique. joinedAsOfRound : Round -- ^ Round in which the SV joined