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..2194a9a7fd --- /dev/null +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestSvWeights.daml @@ -0,0 +1,525 @@ +-- 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 + 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 + ------------------------- + + [(_, 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) + + + -- 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 + dsoAction = SRARC_AddHostedSv DsoRules_AddHostedSv with + newSvParty = hostedSv + newSvName = hostedSvName + newSvRewardWeight = hostedSvWeight + joinedAsOfRound = round.round + svOperator = newSvOperator + + checkDsoRulesHostedSv app hostedSv + (mkHostedSvInfo hostedSvName round hostedSvWeight newSvOperator) + + checkSvRewardBeneficiaries app hostedSv newSvOperator + SvRewardBeneficiaries with + dso + sv = hostedSv + svOperator = newSvOperator + beneficiaries = [] + + + -- 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 + svRewardBeneficiariesCid = hostedSvSvRewardBeneficiariesCid + newBeneficiaries = hostedSvExtraBeneficiaries + svParty = hostedSv + + checkSvRewardBeneficiaries app hostedSv newSvOperator + 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 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 + svRewardBeneficiariesCid = hostedSvSvRewardBeneficiariesCid + newBeneficiaries = updatedHostedSvExtraBeneficiaries + svParty = hostedSv + + checkSvRewardBeneficiaries app hostedSv newSvOperator + 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 + + + -- Clearing SV beneficiaries + ----------------------------- + -- 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 + svRewardBeneficiariesCid = hostedSvSvRewardBeneficiariesCid + newBeneficiaries = [] + 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 (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 + + -- 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 + 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 + dsoAction = SRARC_AddHostedSv DsoRules_AddHostedSv with + newSvParty = hostedSv + newSvName = hostedSvName + newSvRewardWeight = updatedHostedSvWeight + joinedAsOfRound = round.round + svOperator = sv1 + + checkDsoRulesHostedSv app hostedSv + (mkHostedSvInfo hostedSvName round updatedHostedSvWeight sv1) + 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 + 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 + -- 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 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 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 + [(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 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 + checkSvRewardStateExists app ghostSvName + + + -- Creating SV coupons + ----------------------- + + -- 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 + 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 + + pure () + + +-- Helpers +----------- + +getHostedSvsMap : DsoRules -> Map.Map Party HostedSvInfo +getHostedSvsMap rules = fromOptional Map.empty rules.hostedSvs + +getOffboardedHostedSvsMap : DsoRules -> Map.Map Party OffboardedHostedSvInfo +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) + 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 + +checkDsoRulesSv : AmuletApp -> Party -> SvInfo -> Script () +checkDsoRulesSv app sv expected = do + svInfo <- getSvInfoByParty app sv + svInfo === expected + +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 -> 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 = + 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, 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) + [svRewardBeneficiariesCid] <- map fst <$> + queryFilter @SvRewardBeneficiaries app.dso (\b -> b.sv == hostedSvParty && b.svOperator == svOperator) + 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/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..92fab4e2be 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoBootstrap.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoBootstrap.daml @@ -74,6 +74,8 @@ template DsoBootstrap with config 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 5db7ad0b6e..b68f5d1e2c 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) @@ -115,6 +115,14 @@ data DsoRules_ActionRequiringConfirmation -- ^ Voted action to create an UnallocatedUnclaimedActivityRecord contract. | SRARC_CreateBootstrapExternalPartyConfigStateInstruction DsoRules_CreateBootstrapExternalPartyConfigStateInstruction -- ^ Create BootstrapExternalPartyConfigStateInstruction + | 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 a hosted SV. + | SRARC_OffboardHostedSv DsoRules_OffboardHostedSv + -- ^ 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 @@ -126,10 +134,18 @@ 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 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 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 @@ -138,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. @@ -146,6 +167,20 @@ 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 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) + + -- | Choice return types ------------------------- -- In order to support upgrades of the Daml models, all choices should return records, which can @@ -156,6 +191,14 @@ 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_AddHostedSvResult = DsoRules_AddHostedSvResult with + newDsoRules : ContractId DsoRules + svRewardStateCid : Optional (ContractId SvRewardState) + svRewardBeneficiariesCid : ContractId SvRewardBeneficiaries + data DsoRules_OffboardSvResult = DsoRules_OffboardSvResult with newDsoRules : ContractId DsoRules @@ -200,6 +243,18 @@ data DsoRules_SetConfigResult = DsoRules_SetConfigResult with data DsoRules_UpdateSvRewardWeightResult = DsoRules_UpdateSvRewardWeightResult with newDsoRules : ContractId DsoRules +data DsoRules_UpdateSvRewardWeight_V2Result = DsoRules_UpdateSvRewardWeight_V2Result with + newDsoRules : ContractId DsoRules + +data DsoRules_RemoveHostedSvResult = DsoRules_RemoveHostedSvResult with + newDsoRules : ContractId DsoRules + +data DsoRules_SetSvRewardBeneficiariesResult = DsoRules_SetSvRewardBeneficiariesResult with + svRewardBeneficiariesCid : ContractId SvRewardBeneficiaries + +data DsoRules_MigrateHostedSvsResult = DsoRules_MigrateHostedSvsResult with + newDsoRules : ContractId DsoRules + data DsoRules_GrantFeaturedAppRightResult = DsoRules_GrantFeaturedAppRightResult with featuredAppRight : ContractId FeaturedAppRight @@ -264,6 +319,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) @@ -489,16 +547,65 @@ data TrafficState = TrafficState with consumedTraffic: Int -- ^ Bytes of extra traffic consumed before the decentralized synchronizer was bootstrapped. deriving (Eq, Show) + +-- | On-ledger representation of the SV reward beneficiary distribution for an SV. +-- +-- 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. If the beneficiary list is empty, 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 + 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 + unique (map fst beneficiaries) + && all (> 0) (map snd beneficiaries) + && notElem sv (map fst beneficiaries) + + signatory dso + observer sv + + +-- 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 - 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. + offboardedHostedSvs : Optional (Map.Map Party OffboardedHostedSvInfo) where ensure config.numUnclaimedRewardsThreshold > 0 @@ -512,6 +619,7 @@ template DsoRules with -- Sv management ------------------------ + -- TODO: Deprecate choice DsoRules_AddSv : DsoRules_AddSvResult with newSvParty : Party @@ -523,6 +631,15 @@ template DsoRules with do newDsoRules <- dsoRules_addSv this arg return DsoRules_AddSvResult with .. + choice DsoRules_AddSvNode : DsoRules_AddSvOperatorResult + with + newSvOperatorParty : Party + newSvNodeName : Text + newSvNodeParticipantId : Text + controller dso + do newDsoRules <- dsoRules_AddSvNode this arg + return DsoRules_AddSvOperatorResult with .. + choice DsoRules_OffboardSv : DsoRules_OffboardSvResult with sv : Party @@ -553,9 +670,57 @@ template DsoRules with config initialTrafficState isDevNet + hostedSvs + offboardedHostedSvs return DsoRules_OffboardSvResult with .. + choice DsoRules_AddHostedSv : DsoRules_AddHostedSvResult + with + newSvParty : Party + newSvName : Text + newSvRewardWeight : Int + joinedAsOfRound : Round + svOperator : Party + controller dso + do + -- Sanity checks + let isSvOperator = flip Map.member svs + require "svOperator is an SV operator" $ isSvOperator svOperator + + 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 + name = newSvName + joinedAsOfRound + svRewardWeight = newSvRewardWeight + svOperator + updatedHostedSvs = Map.insert newSvParty hostedSvInfo (fromOptional Map.empty hostedSvs) + + -- create a default SvRewardBeneficiaries contract with no beneficiaries + svRewardBeneficiariesCid <- + create SvRewardBeneficiaries with + dso + sv = newSvParty + svOperator + beneficiaries = [] + + newDsoRules <- create this with hostedSvs = Some updatedHostedSvs + + pure DsoRules_AddHostedSvResult with newDsoRules; svRewardStateCid; svRewardBeneficiariesCid + -- Update an SV's Status report nonconsuming choice DsoRules_SubmitStatusReport : DsoRules_SubmitStatusReportResult with @@ -576,6 +741,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. @@ -962,6 +1128,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 `hostedSvs`. choice DsoRules_UpdateSvRewardWeight : DsoRules_UpdateSvRewardWeightResult with svParty : Party @@ -977,6 +1148,161 @@ template DsoRules with newDsoRules <- create this with svs = newSvs return DsoRules_UpdateSvRewardWeightResult with .. + choice DsoRules_UpdateSvRewardWeight_V2 : DsoRules_UpdateSvRewardWeight_V2Result + with + svParty : Party + newRewardWeight : Int + controller dso + do + 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 + + -- | 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 + 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 hostedSvInfo -> do + let newHostedSvs = Map.delete svParty hostedSvsMap + offboardedHostedSvInfo = OffboardedHostedSvInfo with name = hostedSvInfo.name + newDsoRules <- create this with + hostedSvs = Some newHostedSvs + offboardedHostedSvs = Some $ Map.insert svParty offboardedHostedSvInfo offboardedHostedSvsMap + pure DsoRules_RemoveHostedSvResult with newDsoRules + + -- | Replaces the SV reward beneficiary distribution for an SV. + -- + -- 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 + 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 + 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`. + -- + -- 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 + -- 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 + rewardStateCid : ContractId SvRewardState + sv : Party -- ^ The SV operator performing the migration. + controller sv + do + case Map.lookup sv svs of + None -> fail "SV is not an SV operator" + Some svInfo -> do + -- Sanity checks + 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 + updatedDsoRulesWithLegacyWeightInvalidated <- create this with svs = newSvs + + -- Archive SvRewardState + _ <- fetchAndArchive (ForSv with dso; svName = svInfo.name) rewardStateCid + + -- 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 -- App rights management @@ -1050,6 +1376,7 @@ template DsoRules with -- SV onboarding ---------------- + -- TODO: Deprecate nonconsuming choice DsoRules_StartSvOnboarding : DsoRules_StartSvOnboardingResult with candidateName : Text @@ -1065,6 +1392,7 @@ template DsoRules with onboardingRequest <- create SvOnboardingRequest with .. return DsoRules_StartSvOnboardingResult with .. + -- TODO: Deprecate nonconsuming choice DsoRules_ExpireSvOnboardingRequest : DsoRules_ExpireSvOnboardingRequestResult with cid: ContractId SvOnboardingRequest @@ -1075,6 +1403,7 @@ template DsoRules with exercise cid SvOnboardingRequest_Expire return DsoRules_ExpireSvOnboardingRequestResult + -- TODO: Deprecate nonconsuming choice DsoRules_ArchiveSvOnboardingRequest : DsoRules_ArchiveSvOnboardingRequestResult with svOnboardingRequestCid: ContractId SvOnboardingRequest @@ -1089,6 +1418,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 @@ -1115,6 +1445,7 @@ template DsoRules with expiresAt return DsoRules_ConfirmSvOnboardingResult with .. + -- TODO: Deprecate choice DsoRules_ExpireSvOnboardingConfirmed : DsoRules_ExpireSvOnboardingConfirmedResult with cid: ContractId SvOnboardingConfirmed @@ -1299,6 +1630,7 @@ template DsoRules with -- Reward management driven directly by the DSO delegates --------------------------------------------------------- + -- TODO: Deprecate once all SVs have migrated to `hostedSvs`. nonconsuming choice DsoRules_ReceiveSvRewardCoupon : DsoRules_ReceiveSvRewardCouponResult with sv : Party @@ -1347,6 +1679,89 @@ template DsoRules with svRewardState = newRewardStateCid svRewardCoupons = couponCids + nonconsuming choice DsoRules_ReceiveSvRewardCoupon_V2 : DsoRules_ReceiveSvRewardCoupon_V2Result + with + openRoundCid : ContractId OpenMiningRound + -- 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 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, ContractId SvRewardBeneficiaries)] + sv : Party -- ^ The SV operator hosting the SVs. + 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 "RewardState contract ids are unique" (unique $ map fst rewardStatesWithBeneficiaries) + + svRewardStatesWithSvRewardCoupons <- + forA rewardStatesWithBeneficiaries $ \(rewardStateCid, svRewardBeneficiariesCid) -> 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 $ "SV " <> show rewardState.svName <> " is not registered" + Some (hostedSv, hostedSvInfo) -> do + require + ("Only SV operator " <> show hostedSvInfo.svOperator <> " can create SV rewards for SV " <> show hostedSv) + (hostedSvInfo.svOperator == sv) + + let svRewardWeight = hostedSvInfo.svRewardWeight + 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) + 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 + 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 ----------------------------------------------------------------- @@ -1714,6 +2129,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_AddHostedSv choiceArg -> void $ exercise dsoRulesCid choiceArg + SRARC_UpdateSvRewardWeight_V2 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 case ansEntryContextAction of @@ -1784,17 +2203,66 @@ 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 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" $ 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_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 newSvNodeName 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 newSvNodeName this0) + pure this0 + + -- create per SV operator party contracts + let initialAmuletPriceVote = None + createPerSvPartyContracts dso newSvOperatorParty newSvNodeName noSynchronizerNodes initialAmuletPriceVote (getVoteCooldownTime config) + -- register the new operator in the DsoRules + let svInfo = SvInfo with + 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 = newSvNodeParticipantId + create this with + 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) dsoRules_addSv this0 arg@DsoRules_AddSv{..} = do @@ -1936,6 +2404,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 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.