diff --git a/daml/dars.lock b/daml/dars.lock index bbcd90ddf7..99a113fcb1 100644 --- a/daml/dars.lock +++ b/daml/dars.lock @@ -68,6 +68,7 @@ splice-dso-governance 0.1.21 2d306cfe8cdb3daf2d21f84dfecc3e2f26a41504e58fe25cb7f splice-dso-governance 0.1.22 5c28530209b9ab37c5f187132cd826709bb18b0efe28411488ab750870414738 splice-dso-governance 0.1.23 0c94a036ac5168a1dee26b435838e062f0d2f47d6eac49303978228ae559edb9 splice-dso-governance 0.1.24 4974c654485d4ecaa6b5caf8ef3c2679efa8195c4b50d4965a8fff1b72e8efa4 +splice-dso-governance 0.1.25 0f18b9a3ff0e278dea3cc2a575fa0e0821c64aa42dc849f51ab54711fca8bd2c splice-dso-governance 0.1.3 b0ae3cc03e418790305a3c15f761fe495572de5827f8d322fb8b96996b783c13 splice-dso-governance 0.1.4 dc24fd18b4d151cd1e0ff6bfb7438bafb2f50fe076d0f16f50565e60b153a0be splice-dso-governance 0.1.5 9e3ca1d22ad495dfabf3d61acae3dc1a7718f527f02092280b58cf69edfdc84c @@ -75,7 +76,7 @@ splice-dso-governance 0.1.6 4e7653cfbf7ca249de4507aca9cd3b91060e5489042a522c589d splice-dso-governance 0.1.7 d406eba1132d464605f4dae3edf8cf5ecbbb34bd8edef0e047e7e526d328718c splice-dso-governance 0.1.8 1790a114f83d5f290261fae1e7e46fba75a861a3dd603c6b4ef6b67b49053948 splice-dso-governance 0.1.9 9ee83bfd872f91e659b8a8439c5b4eaf240bcf6f19698f884d7d7993ab48c401 -splice-dso-governance-test 0.1.30 13e5d00f8e2874f2d7480a1df1e622a97733dfa97406a9a04ff293823ab942b3 +splice-dso-governance-test 0.1.31 3766649982806b360bede1f12df9584bb336ec95881fe7021f81f5785e3dfd7c splice-token-standard-test 1.0.13 a556574314ab5ecbfa04b04a6b6c9259cf90388461fe307ba712257ad5993a6b splice-token-test-dummy-holding 0.0.1 1cd171c6c42ab46dc9cf12d80c6111369e00cea5cdf054924b4f26ce94b1ef5b splice-token-test-dummy-holding 0.0.2 4f40fb033ef3db89623642c1b494e846097fa32af138b3864a63aa15937a323d diff --git a/daml/dars/splice-dso-governance-0.1.25.dar b/daml/dars/splice-dso-governance-0.1.25.dar new file mode 100644 index 0000000000..57be88df95 Binary files /dev/null and b/daml/dars/splice-dso-governance-0.1.25.dar differ diff --git a/daml/splice-dso-governance-test/daml.yaml b/daml/splice-dso-governance-test/daml.yaml index 7521aa2e98..abc5bd9354 100644 --- a/daml/splice-dso-governance-test/daml.yaml +++ b/daml/splice-dso-governance-test/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.4.11 name: splice-dso-governance-test source: daml -version: 0.1.30 +version: 0.1.31 dependencies: - daml-prim - daml-stdlib 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..25006b6e2b 100644 --- a/daml/splice-dso-governance-test/daml/Splice/Scripts/DsoTestUtils.daml +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/DsoTestUtils.daml @@ -330,7 +330,7 @@ executeAllDefinitiveVotes app = do now <- getTime requests <- query @VoteRequest app.dso forA_ requests $ \(requestCid, request) -> do - let activeSvs = Set.fromList $ map (.name) $ Map.values rules.svs + let activeSvs = Set.fromList $ Map.keys rules.svs let execute = request.voteBefore <= now || (activeSvs == Set.fromList (Map.keys request.votes)) when execute $ do @@ -356,6 +356,8 @@ initiateAndCastVote app (initiator::others) targetEffectiveAt action = do requestCid vote = Vote with sv + castBy = sv + castByRole = VCR_Operator accept = True reason = Reason with url = ""; body = "✓" optCastAt = None @@ -379,6 +381,8 @@ initiateAndAcceptVote app (initiator::others) action = do requestCid vote = Vote with sv + castBy = sv + castByRole = VCR_Operator accept = True reason = Reason with url = ""; body = "✓" optCastAt = None @@ -392,6 +396,8 @@ castVote app sv requestCid vote = do requestCid = requestCid vote = Vote with sv = sv + castBy = sv + castByRole = VCR_Operator accept = vote reason = Reason with url = "" diff --git a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestGovernance.daml b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestGovernance.daml index 0231fd5f23..da5178e2fc 100644 --- a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestGovernance.daml +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestGovernance.daml @@ -25,6 +25,7 @@ import Splice.DSO.AmuletPrice import Splice.AmuletConfig import Splice.CometBft import Splice.DecentralizedSynchronizer +import Splice.DSO.GovernanceVoter import Splice.Scripts.DsoTestUtils import Splice.Testing.Registries.AmuletRegistry.Parameters @@ -34,6 +35,293 @@ import Splice.Testing.Utils -- Vote Requests ---------------- +-- | Tests the governance-voter binding lifecycle and authorization. +testSvGovernanceVoterBindingLifecycle : Script () +testSvGovernanceVoterBindingLifecycle = do + (_app, dso, (sv1, _sv2, _sv3, _sv4)) <- initMainNet + governanceVoter <- allocateParty "governance-voter" + otherParty <- allocateParty "wrong-governance-voter" + + -- Bootstrap/self-voting remains valid. + selfBindingCid <- submit sv1 $ createCmd SvGovernanceVoter with + dso + sv = sv1 + governanceVoter = sv1 + Some selfBinding <- queryContractId dso selfBindingCid + selfBinding.governanceVoter === sv1 + + -- The DSO party is never a valid governance voter. + submitMustFail sv1 $ createCmd SvGovernanceVoter with + dso + sv = sv1 + governanceVoter = dso + + -- Only the represented SV can declare the binding. + submitMustFail governanceVoter $ createCmd SvGovernanceVoter with + dso + sv = sv1 + governanceVoter + + rotateResult <- submit sv1 $ exerciseCmd selfBindingCid RotateGovernanceVoter with + newGovernanceVoter = governanceVoter + None <- queryContractId dso selfBindingCid + Some rotatedBinding <- queryContractId dso rotateResult.bindingCid + rotatedBinding.governanceVoter === governanceVoter + + -- No-op rotation is rejected. + submitMustFail sv1 $ exerciseCmd rotateResult.bindingCid RotateGovernanceVoter with + newGovernanceVoter = governanceVoter + + -- Rotation to the DSO party is rejected. + submitMustFail sv1 $ exerciseCmd rotateResult.bindingCid RotateGovernanceVoter with + newGovernanceVoter = dso + + -- Non-SV parties cannot rotate or clear the binding. + submitMustFail otherParty $ exerciseCmd rotateResult.bindingCid RotateGovernanceVoter with + newGovernanceVoter = otherParty + submitMustFail otherParty $ exerciseCmd rotateResult.bindingCid ClearGovernanceVoter + + submit sv1 $ exerciseCmd rotateResult.bindingCid ClearGovernanceVoter + None <- queryContractId dso rotateResult.bindingCid + pure () + +-- | Tests governance-voter casting while preserving represented-SV vote slots. +testGovernanceVoterCastPath : Script () +testGovernanceVoterCastPath = do + (_app, dso, (sv1, sv2, _sv3, _sv4)) <- initMainNet + governanceVoter1 <- allocateParty "cast-governance-voter-1" + governanceVoter2 <- allocateParty "cast-governance-voter-2" + wrongVoter <- allocateParty "wrong-cast-governance-voter" + newSv <- allocateParty "cast-new-sv" + + [(dsoRulesCid, _)] <- query @DsoRules dso + [(_, openRound), _, _] <- sortOn (._2.round) <$> query @OpenMiningRound dso + bindingCid <- submit sv1 $ createCmd SvGovernanceVoter with + dso + sv = sv1 + governanceVoter = governanceVoter1 + + let governanceVote signer accept body = Vote with + sv = sv1 + castBy = signer + castByRole = VCR_GovernanceVoter + accept + reason = Reason with url = ""; body + optCastAt = None + let operatorVote accept body = Vote with + sv = sv1 + castBy = sv1 + castByRole = VCR_Operator + accept + reason = Reason with url = ""; body + optCastAt = None + let requestOffboardSv = submit (actAs sv2 <> readAs dso) $ exerciseCmd dsoRulesCid DsoRules_RequestVote with + requester = sv2 + action = ARC_DsoRules with + dsoAction = SRARC_OffboardSv DsoRules_OffboardSv with sv = sv1 + reason = Reason with url = ""; body = "cast path" + targetEffectiveAt = None + voteRequestTimeout = Some (days 7) + + requestResult <- requestOffboardSv + castResult <- submit (actAs governanceVoter1 <> readAs dso) $ exerciseCmd dsoRulesCid DsoRules_CastGovernanceVote with + requestCid = requestResult.voteRequest + bindingCid + vote = governanceVote governanceVoter1 False "governance voter update" + Some request <- queryContractId dso castResult.voteRequest + Map.size request.votes === 2 + case Map.lookup sv1 request.votes of + None -> fail "represented SV vote missing" + Some vote -> do + vote.sv === sv1 + vote.castBy === governanceVoter1 + vote.castByRole === VCR_GovernanceVoter + vote.accept === False + + passTime (minutes 1) + operatorResult <- submit (actAs sv1 <> readAs dso) $ exerciseCmd dsoRulesCid DsoRules_CastVote with + requestCid = castResult.voteRequest + vote = operatorVote True "operator overwrite" + Some request <- queryContractId dso operatorResult.voteRequest + case Map.lookup sv1 request.votes of + None -> fail "operator vote missing" + Some vote -> do + vote.castBy === sv1 + vote.castByRole === VCR_Operator + vote.accept === True + + passTime (minutes 1) + submitMustFail (actAs governanceVoter1 <> readAs dso) $ exerciseCmd dsoRulesCid DsoRules_CastGovernanceVote with + requestCid = operatorResult.voteRequest + bindingCid + vote = governanceVote governanceVoter1 False "governance voter cannot overwrite operator" + Some request <- queryContractId dso operatorResult.voteRequest + case Map.lookup sv1 request.votes of + None -> fail "operator vote missing after rejected governance-voter overwrite" + Some vote -> do + vote.castBy === sv1 + vote.castByRole === VCR_Operator + vote.accept === True + + unsupportedRequest <- submit (actAs sv2 <> readAs dso) $ exerciseCmd dsoRulesCid DsoRules_RequestVote with + requester = sv2 + action = ARC_DsoRules with + dsoAction = SRARC_AddSv DsoRules_AddSv with + newSvParty = newSv + newSvName = "cast-new-sv" + newSvRewardWeight = 1 + newSvParticipantId = "cast-new-sv-participant" + joinedAsOfRound = openRound.round + reason = Reason with url = ""; body = "unsupported action" + targetEffectiveAt = None + voteRequestTimeout = Some (days 7) + submitMustFail (actAs governanceVoter1 <> readAs dso) $ exerciseCmd dsoRulesCid DsoRules_CastGovernanceVote with + requestCid = unsupportedRequest.voteRequest + bindingCid + vote = governanceVote governanceVoter1 False "unsupported" + + wrongVoterRequest <- requestOffboardSv + submitMustFail (actAs wrongVoter <> readAs dso) $ exerciseCmd dsoRulesCid DsoRules_CastGovernanceVote with + requestCid = wrongVoterRequest.voteRequest + bindingCid + vote = governanceVote wrongVoter False "wrong voter" + + rotateResult <- submit sv1 $ exerciseCmd bindingCid RotateGovernanceVoter with + newGovernanceVoter = governanceVoter2 + rotatedRequest <- requestOffboardSv + submitMustFail (actAs governanceVoter1 <> readAs dso) $ exerciseCmd dsoRulesCid DsoRules_CastGovernanceVote with + requestCid = rotatedRequest.voteRequest + bindingCid = rotateResult.bindingCid + vote = governanceVote governanceVoter1 False "rotated old voter" + passTime (minutes 1) + _ <- submit (actAs governanceVoter2 <> readAs dso) $ exerciseCmd dsoRulesCid DsoRules_CastGovernanceVote with + requestCid = rotatedRequest.voteRequest + bindingCid = rotateResult.bindingCid + vote = governanceVote governanceVoter2 False "rotated new voter" + + _ <- submit sv1 $ exerciseCmd rotateResult.bindingCid ClearGovernanceVoter + clearedRequest <- requestOffboardSv + submitMustFail (actAs governanceVoter2 <> readAs dso) $ exerciseCmd dsoRulesCid DsoRules_CastGovernanceVote with + requestCid = clearedRequest.voteRequest + bindingCid = rotateResult.bindingCid + vote = governanceVote governanceVoter2 False "cleared binding" + +-- | Tests the Phase 1 governance-voter action taxonomy. +testGovernanceVoterActionTaxonomy : Script () +testGovernanceVoterActionTaxonomy = do + (_app, dso, (sv1, sv2, _sv3, _sv4)) <- initMainNet + provider <- allocateParty "taxonomy-provider" + beneficiary <- allocateParty "taxonomy-beneficiary" + newSv <- allocateParty "taxonomy-new-sv" + now <- getTime + [(_, dsoRules)] <- query @DsoRules dso + [(_, amuletRules)] <- query @AmuletRules dso + [(_, openRound), _, _] <- sortOn (._2.round) <$> query @OpenMiningRound dso + rightCid <- submit dso $ createCmd FeaturedAppRight with + dso + provider + + forA_ + [ ARC_DsoRules with + dsoAction = SRARC_GrantFeaturedAppRight DsoRules_GrantFeaturedAppRight with provider + , ARC_DsoRules with + dsoAction = SRARC_RevokeFeaturedAppRight DsoRules_RevokeFeaturedAppRight with rightCid + , ARC_DsoRules with + dsoAction = SRARC_SetConfig DsoRules_SetConfig with + newConfig = dsoRules.config + baseConfig = Some dsoRules.config + , ARC_DsoRules with + dsoAction = SRARC_UpdateSvRewardWeight DsoRules_UpdateSvRewardWeight with + svParty = sv1 + newRewardWeight = 1 + , ARC_DsoRules with + dsoAction = SRARC_CreateUnallocatedUnclaimedActivityRecord DsoRules_CreateUnallocatedUnclaimedActivityRecord with + beneficiary + amount = 1.0 + reason = "taxonomy test" + expiresAt = now `addRelTime` (days 1) + , ARC_DsoRules with + dsoAction = SRARC_OffboardSv DsoRules_OffboardSv with sv = sv2 + , ARC_AmuletRules with + amuletRulesAction = CRARC_SetConfig AmuletRules_SetConfig with + newConfig = amuletRules.configSchedule.initialValue + baseConfig = amuletRules.configSchedule.initialValue + ] + (\action -> isGovernanceVoterAction action === True) + + forA_ + [ ARC_DsoRules with + dsoAction = SRARC_AddSv DsoRules_AddSv with + newSvParty = newSv + newSvName = "taxonomy-new-sv" + newSvRewardWeight = 1 + newSvParticipantId = "taxonomy-participant" + joinedAsOfRound = openRound.round + , ARC_DsoRules with + dsoAction = SRARC_ConfirmSvOnboarding DsoRules_ConfirmSvOnboarding with + newSvParty = newSv + newSvName = "taxonomy-new-sv" + newParticipantId = "taxonomy-participant" + newSvRewardWeight = 1 + reason = "taxonomy test" + , ARC_DsoRules with + dsoAction = SRARC_CreateExternalPartyAmuletRules DsoRules_CreateExternalPartyAmuletRules + , ARC_DsoRules with + dsoAction = SRARC_CreateTransferCommandCounter DsoRules_CreateTransferCommandCounter with + sender = provider + , ARC_DsoRules with + dsoAction = SRARC_CreateBootstrapExternalPartyConfigStateInstruction DsoRules_CreateBootstrapExternalPartyConfigStateInstruction + , ExtActionRequiringConformation with dummyUnitField = () + ] + (\action -> isGovernanceVoterAction action === False) + +-- | Tests that vote updates keep one vote slot for the represented SV. +testVoteUpdateKeepsOneSlotPerSv : Script () +testVoteUpdateKeepsOneSlotPerSv = do + (_app, dso, (sv1, sv2, _sv3, _sv4)) <- initMainNet + [(dsoRulesCid, _)] <- query @DsoRules dso + + result <- submit (actAs sv1 <> readAs dso) $ exerciseCmd dsoRulesCid DsoRules_RequestVote with + requester = sv1 + action = ARC_DsoRules with + dsoAction = SRARC_OffboardSv DsoRules_OffboardSv with + sv = sv2 + reason = Reason with url = ""; body = "taxonomy tally test" + targetEffectiveAt = None + voteRequestTimeout = Some (days 7) + let initialRequestCid = result.voteRequest + + Some initialRequest <- queryContractId dso initialRequestCid + Map.size initialRequest.votes === 1 + case Map.lookup sv1 initialRequest.votes of + None -> fail "requester vote missing" + Some vote -> do + vote.sv === sv1 + vote.castBy === sv1 + vote.castByRole === VCR_Operator + + passTime (minutes 1) + result <- submit (actAs sv1 <> readAs dso) $ exerciseCmd dsoRulesCid DsoRules_CastVote with + requestCid = initialRequestCid + vote = Vote with + sv = sv1 + castBy = sv1 + castByRole = VCR_Operator + accept = False + reason = Reason with url = ""; body = "updated vote" + optCastAt = None + + Some updatedRequest <- queryContractId dso result.voteRequest + Map.size updatedRequest.votes === 1 + updatedRequest.trackingCid === Some initialRequestCid + case Map.lookup sv1 updatedRequest.votes of + None -> fail "updated requester vote missing" + Some vote -> do + vote.sv === sv1 + vote.castBy === sv1 + vote.castByRole === VCR_Operator + vote.accept === False + -- | Tests vote request machinery on featured app right granting and revoking without effectivity. testVoteRequestAcceptanceWithoutEffectivity : Script () testVoteRequestAcceptanceWithoutEffectivity = do @@ -414,6 +702,8 @@ testRacingSvRemoval = do requestCid vote = Vote with sv = sv1 + castBy = sv1 + castByRole = VCR_Operator accept = True reason = Reason with url = ""; body = "OK, let them go" optCastAt = None @@ -421,6 +711,8 @@ testRacingSvRemoval = do requestCid = result1.voteRequest vote = Vote with sv = sv2 + castBy = sv2 + castByRole = VCR_Operator accept = True reason = Reason with url = ""; body = "OK, let them go" optCastAt = None @@ -428,6 +720,8 @@ testRacingSvRemoval = do requestCid = result2.voteRequest vote = Vote with sv = sv3 + castBy = sv3 + castByRole = VCR_Operator accept = True reason = Reason with url = ""; body = "OK, let them go" optCastAt = None @@ -435,6 +729,8 @@ testRacingSvRemoval = do requestCid = result3.voteRequest vote = Vote with sv = sv4 + castBy = sv4 + castByRole = VCR_Operator accept = True reason = Reason with url = ""; body = "OK, let them go" optCastAt = None @@ -489,7 +785,7 @@ testRacingSvRemoval = do sv = Some sv1 result.completedAt === now - result.offboardedVoters === ["sv2"] + result.offboardedVoters === [partyToText sv2] result.abstainingSvs === [] result.outcome === VRO_Accepted with effectiveAt = now @@ -541,6 +837,8 @@ testRacingSvRemoval = do requestCid = result5.voteRequest vote = Vote with sv = sv4 + castBy = sv4 + castByRole = VCR_Operator accept = True reason = Reason with url = ""; body = "yes, let me go please!" optCastAt = None @@ -1072,6 +1370,8 @@ testVoteCastingCooldown = do requestCid vote = Vote with sv = sv2 + castBy = sv2 + castByRole = VCR_Operator accept = False reason = Reason with url = ""; body = "noooo, let me stay on please!" optCastAt = None @@ -1082,6 +1382,8 @@ testVoteCastingCooldown = do requestCid vote = Vote with sv = sv2 + castBy = sv2 + castByRole = VCR_Operator accept = False reason = Reason with url = ""; body = "a better rebuttal" optCastAt = None @@ -1091,6 +1393,8 @@ testVoteCastingCooldown = do requestCid vote = Vote with sv = sv1 + castBy = sv1 + castByRole = VCR_Operator accept = True reason = Reason with url = ""; body = "a better reason" optCastAt = None @@ -1103,6 +1407,8 @@ testVoteCastingCooldown = do requestCid vote = Vote with sv = sv2 + castBy = sv2 + castByRole = VCR_Operator accept = False reason = Reason with url = ""; body = "a better rebuttal" optCastAt = None @@ -1113,6 +1419,8 @@ testVoteCastingCooldown = do requestCid vote = Vote with sv = sv1 + castBy = sv1 + castByRole = VCR_Operator accept = True reason = Reason with url = ""; body = "a better reason" optCastAt = None diff --git a/daml/splice-dso-governance/daml.yaml b/daml/splice-dso-governance/daml.yaml index 485e14430e..98a104b890 100644 --- a/daml/splice-dso-governance/daml.yaml +++ b/daml/splice-dso-governance/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.4.11 name: splice-dso-governance source: daml -version: 0.1.24 +version: 0.1.25 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-dso-governance/daml/Splice/DSO/GovernanceVoter.daml b/daml/splice-dso-governance/daml/Splice/DSO/GovernanceVoter.daml new file mode 100644 index 0000000000..97cd27eeb8 --- /dev/null +++ b/daml/splice-dso-governance/daml/Splice/DSO/GovernanceVoter.daml @@ -0,0 +1,41 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +module Splice.DSO.GovernanceVoter where + +import Splice.Util + +data RotateGovernanceVoterResult = RotateGovernanceVoterResult with + bindingCid : ContractId SvGovernanceVoter + deriving (Eq, Show) + +data ClearGovernanceVoterResult = ClearGovernanceVoterResult + deriving (Eq, Show) + +template SvGovernanceVoter + with + dso : Party + sv : Party + governanceVoter : Party + where + signatory sv + observer dso, governanceVoter + + ensure + sv /= dso + && governanceVoter /= dso + + choice RotateGovernanceVoter : RotateGovernanceVoterResult + with + newGovernanceVoter : Party + controller sv + do + require "New governance voter must differ" (newGovernanceVoter /= governanceVoter) + require "Governance voter must not be dso" (newGovernanceVoter /= dso) + bindingCid <- create this with governanceVoter = newGovernanceVoter + pure RotateGovernanceVoterResult with .. + + choice ClearGovernanceVoter : ClearGovernanceVoterResult + controller sv + do + pure ClearGovernanceVoterResult diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 5db7ad0b6e..a559a0e108 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -32,6 +32,7 @@ import Splice.Ans import Splice.SvOnboarding import Splice.DSO.AmuletPrice import Splice.DSO.DecentralizedSynchronizer +import Splice.DSO.GovernanceVoter import Splice.DSO.SvState import Splice.Schedule import Splice.Util @@ -124,6 +125,26 @@ data AnsEntryContext_ActionRequiringConfirmation -- ^ Automated action to reject initial payment of an ans entry. deriving (Eq, Show) +-- | Phase 1 governance-voter action taxonomy. +-- +-- This is deliberately an explicit allowlist. New action constructors are not +-- governance-voter eligible unless they are reviewed and added here. +isGovernanceVoterAction : ActionRequiringConfirmation -> Bool +isGovernanceVoterAction action = case action of + ARC_DsoRules with dsoAction -> case dsoAction of + SRARC_OffboardSv _ -> True + SRARC_GrantFeaturedAppRight _ -> True + SRARC_RevokeFeaturedAppRight _ -> True + SRARC_SetConfig _ -> True + SRARC_UpdateSvRewardWeight _ -> True + SRARC_CreateUnallocatedUnclaimedActivityRecord _ -> True + _ -> False + ARC_AmuletRules with amuletRulesAction -> case amuletRulesAction of + CRARC_SetConfig _ -> True + _ -> False + ARC_AnsEntryContext _ _ -> False + ExtActionRequiringConformation _ -> False + -- | Information about SVs relevant to DSO governance. data SvInfo = SvInfo with name : Text -- ^ Human-readable name; must be unique. @@ -189,6 +210,9 @@ data DsoRules_RequestVoteResult = DsoRules_RequestVoteResult with data DsoRules_CastVoteResult = DsoRules_CastVoteResult with voteRequest : ContractId VoteRequest +data DsoRules_CastGovernanceVoteResult = DsoRules_CastGovernanceVoteResult with + voteRequest : ContractId VoteRequest + data DsoRules_UpdateAmuletPriceVoteResult = DsoRules_UpdateAmuletPriceVoteResult with amuletPriceVote : ContractId AmuletPriceVote @@ -391,8 +415,8 @@ template VoteRequest -- ^ The reason for requesting the execution of the action. Typically a reference to some off-ledger justification. voteBefore : Time -- ^ The time before which votes are accepted, and SHOULD be submitted. - votes : Map.Map Text Vote - -- ^ The votes cast by current or previous SVs. These may be previous SVs in case + votes : Map.Map Party Vote + -- ^ The votes cast by represented SV party. These may be previous SVs in case -- there was an SV change after the vote was requested. trackingCid : Optional (ContractId VoteRequest) -- ^ An optional tracking ContractId to be used for tracking the vote request through its updates. @@ -424,10 +448,17 @@ data VoteRequestOutcome dummyUnitField : () -- ^ Extension constructor (and field) to work around the current lack of upgrading for variants in Daml 3.0 deriving (Eq, Show) +data VoteCastRole + = VCR_Operator + | VCR_GovernanceVoter + deriving(Eq, Show) + -- --- | A vote cast by an SV. +-- | A vote cast for an SV. data Vote = Vote with - sv : Party -- ^ The SV party used to submit the vote. + sv : Party -- ^ The represented SV whose vote slot is updated. + castBy : Party -- ^ The party that signed the vote command. + castByRole : VoteCastRole -- ^ The authority path used to cast the vote. accept : Bool -- ^ Whether the responder accepted the request to execute the action or not. reason : Reason @@ -744,6 +775,8 @@ template DsoRules with require "There is some time to vote" (now < voteBefore) let requesterVote = Vote with sv = requester + castBy = requester + castByRole = VCR_Operator accept = True reason = Reason with url = ""; body = "I accept, as I requested the vote." optCastAt = Some now @@ -753,7 +786,7 @@ template DsoRules with action reason voteBefore - votes = Map.fromList [(requesterName, requesterVote)] + votes = Map.fromList [(requester, requesterVote)] trackingCid = None targetEffectiveAt return DsoRules_RequestVoteResult with .. @@ -768,9 +801,9 @@ template DsoRules with do -- validate vote parameters requireWellformedVote config vote - voterName <- case Map.lookup vote.sv svs of + case Map.lookup vote.sv svs of None -> fail "Voter is not an SV" - Some info -> pure info.name + Some _ -> pure () -- validate and archive request request <- fetchAndArchive (ForDso with dso) requestCid -- rate limit casting of votes by the same SV to avoid them blocking others from making progress @@ -778,15 +811,62 @@ template DsoRules with -- `vote.optCastAt`. We'll use that in the future when adding support for larger -- prepare-submission delays for external parties. enforceCooldown ("voteCooldownTime for " <> partyToText vote.sv) (getVoteCooldownTime config) $ do - pastVote <- Map.lookup voterName request.votes + pastVote <- Map.lookup vote.sv request.votes pastVote.optCastAt -- store vote now <- getTime + let recordedVote = vote with + castBy = vote.sv + castByRole = VCR_Operator + optCastAt = Some now voteRequest <- create request with - votes = Map.insert voterName (vote with optCastAt = Some now) request.votes + votes = Map.insert vote.sv recordedVote request.votes trackingCid = Some (fromOptional requestCid request.trackingCid) return DsoRules_CastVoteResult with .. + -- Note that this choice can be used to both cast the initial governance-voter + -- vote, and update a vote for the represented SV. + nonconsuming choice DsoRules_CastGovernanceVote : DsoRules_CastGovernanceVoteResult + with + requestCid : ContractId VoteRequest + bindingCid : ContractId SvGovernanceVoter + vote : Vote + controller vote.castBy + do + -- validate vote parameters + requireWellformedVote config vote + binding <- fetch bindingCid + require "Binding dso must match rules dso" (binding.dso == dso) + require "Vote SV must match binding SV" (vote.sv == binding.sv) + require "Vote signer must match binding governance voter" (vote.castBy == binding.governanceVoter) + require "Vote signer role must be governance voter" (vote.castByRole == VCR_GovernanceVoter) + case Map.lookup binding.sv svs of + None -> fail "Voter is not an SV" + Some _ -> pure () + -- validate request before archiving it + request <- fetchChecked (ForDso with dso) requestCid + require "Action is not governance-voter eligible" (isGovernanceVoterAction request.action) + case Map.lookup binding.sv request.votes of + Some pastVote | pastVote.castByRole == VCR_Operator -> + fail "Governance voter cannot overwrite operator vote" + _ -> pure () + archive requestCid + -- rate limit casting of votes by the same represented SV to avoid blocking progress + enforceCooldown ("voteCooldownTime for " <> partyToText binding.sv) (getVoteCooldownTime config) $ do + pastVote <- Map.lookup binding.sv request.votes + pastVote.optCastAt + -- store vote in the represented SV's vote slot + now <- getTime + let recordedVote = vote with + sv = binding.sv + castBy = binding.governanceVoter + castByRole = VCR_GovernanceVoter + optCastAt = Some now + voteRequest <- create request with + votes = Map.insert binding.sv recordedVote request.votes + trackingCid = Some (fromOptional requestCid request.trackingCid) + return DsoRules_CastGovernanceVoteResult with .. + -- We expect the TxHistory log of the SV app to store the choice argument together -- with its result for historical reference. -- @@ -822,14 +902,14 @@ template DsoRules with let s = summarizeDso this request <- fetchAndArchive (ForDso with dso) requestCid - let activeSvs = map (.name) $ Map.values svs + let activeSvs = Map.keys svs let activeSvSet = Set.fromList activeSvs let (validVotes, offboardedVoters) = partitionEithers - [ if voterName `Set.member` activeSvSet then Left vote else Right voterName - | (voterName, vote) <- Map.toList request.votes + [ if voterParty `Set.member` activeSvSet then Left vote else Right (partyToText voterParty) + | (voterParty, vote) <- Map.toList request.votes ] - let abstainingSvs = [ name | name <- activeSvs, not (name `Map.member` request.votes) ] + let abstainingSvs = [ info.name | (svParty, info) <- Map.toList svs, not (svParty `Map.member` request.votes) ] let (yays, nays) = partition (.accept) validVotes let numYays = length yays let numNays = length nays diff --git a/docs/src/sv_operator/index.rst b/docs/src/sv_operator/index.rst index 5c0ef21d93..5cb638696b 100644 --- a/docs/src/sv_operator/index.rst +++ b/docs/src/sv_operator/index.rst @@ -20,6 +20,7 @@ Supervalidators sv_restore.rst sv_security.rst sv_operations.rst + sv_governance_voter.rst sv_scratchnet.rst .. todo:: Add sections top-level sections on upgrades, node onboarding, validator functionality, DR diff --git a/docs/src/sv_operator/sv_governance_voter.rst b/docs/src/sv_operator/sv_governance_voter.rst new file mode 100644 index 0000000000..ec58e28849 --- /dev/null +++ b/docs/src/sv_operator/sv_governance_voter.rst @@ -0,0 +1,48 @@ +.. + Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +.. + SPDX-License-Identifier: Apache-2.0 + +SV Governance Voter Prototype +============================== + +The governance-voter contract work is a prototype for review under +`Canton Development Fund PR #223 `_, +especially Milestone 1: Governance-Voting Identity and CIP. + +Phase 1 preserves the current one-vote-per-SV model. A governance voter is +intended to be an alternate signer for the represented SV's vote on explicitly +supported non-operational governance actions; it is not a new voting unit and +does not add voting weight. + +Vote records carry the represented SV and the party/role that cast the vote. +This attribution is accountability metadata for the authority path; tallying +continues to use the represented SV's vote slot. +Vote slots are keyed by represented SV party, not by SV display name. This keeps +cooldown and overwrite behavior tied to the stable party identifier while +existing review-facing outputs can still render SV names. + +The prototype binding is declared by the represented SV and has no contract key. +The intended invariant is one active governance-voter binding per SV. This slice +keeps that invariant outside the template key space by design, matching the +Phase 1 proposal direction. + +The binding is SV-declared by design: the represented SV can create, rotate, or +clear its governance-voter binding without a Propose-Accept step. Self-binding +(``governanceVoter == sv``) is also allowed by design for bootstrap and +self-voting. The DSO party cannot be used as a governance voter. + +Governance-voter vote submission fetches the binding by contract ID, validates +the signer against the binding, checks the action allowlist, and writes the vote +into the represented SV's vote slot. It cannot overwrite a prior operator-cast +vote for that SV; operator votes remain the precedence path. The operator and +governance-voter paths intentionally share the represented SV's cooldown because +there is still only one vote slot per SV. + +The first contract slice uses a hardcoded Daml allowlist for governance-voter +eligible actions. New ``ActionRequiringConfirmation`` constructors are rejected +by default until reviewed and added deliberately. The proposed allowlist is +intended to be concrete enough for maintainer and CIP review, not a final +statement of upstream governance policy. In particular, inclusion of +``SRARC_OffboardSv`` should be validated through that review because it is a +high-impact governance membership action.