@@ -13,8 +13,10 @@ import Cardano.Api.Experimental (Some (..), obtainCommonConstraints)
1313import Cardano.Api.Ledger (EpochInterval (.. ))
1414
1515import qualified Cardano.Crypto.Hash as L
16+ import qualified Cardano.Ledger.Conway.Genesis as L
1617import qualified Cardano.Ledger.Conway.Governance as L
1718import qualified Cardano.Ledger.Conway.Governance as Ledger
19+ import qualified Cardano.Ledger.Conway.PParams as L
1820import qualified Cardano.Ledger.Hashes as L
1921import qualified Cardano.Ledger.Shelley.LedgerState as L
2022import Cardano.Testnet
@@ -28,6 +30,7 @@ import qualified Data.Aeson.Lens as Aeson
2830import Data.Default.Class
2931import Data.Maybe
3032import Data.Maybe.Strict
33+ import Data.Ratio ((%) )
3134import qualified Data.Text as Text
3235import Data.Text.Encoding (decodeUtf8 )
3336import qualified Data.Vector as Vector
@@ -48,6 +51,7 @@ import Testnet.Process.Cli.Transaction
4851import Testnet.Process.Run (addEnvVarsToConfig , execCli' , mkExecConfig )
4952import Testnet.Process.RunIO (liftIOAnnotated )
5053import Testnet.Property.Util (integrationRetryWorkspace )
54+ import Testnet.Start.Cardano (liftToIntegration )
5155import Testnet.Start.Types
5256import Testnet.Types
5357
@@ -66,12 +70,21 @@ hprop_ledger_events_propose_new_constitution = integrationRetryWorkspace 2 "prop
6670
6771 work <- H. createDirectoryIfMissing $ tempAbsPath' </> " work"
6872
69- -- Generate model for votes
70- let allVotes :: [(String , Int )]
71- allVotes = zip (concatMap (uncurry replicate ) [(4 , " yes" ), (3 , " no" ), (2 , " abstain" )]) [1 .. ]
72- annotateShow allVotes
73-
74- let numVotes :: Int
73+ -- Generate model for votes in two rounds.
74+ -- The Conway DRep voting ratio is: yes / (yes + no + non-voters). Abstainers are excluded
75+ -- from the denominator entirely. Non-voters count against (as implicit no).
76+ -- Round 1: 3 yes, 6 non-voting. Ratio = 3/9 = 33.3%, below 51% threshold - proposal stays alive.
77+ -- Round 2: +1 yes, +3 no, +2 abstain. Ratio = (3+1)/(9-2) = 57.1% (abstain excluded), triggers ratification.
78+ let round1Votes :: [(String , Int )]
79+ round1Votes = zip (replicate 3 " yes" ) [1 .. ]
80+ annotateShow round1Votes
81+
82+ let round2Votes :: [(String , Int )]
83+ round2Votes = zip (concatMap (uncurry replicate ) [(1 , " yes" ), (3 , " no" ), (2 , " abstain" )]) [4 .. ]
84+ annotateShow round2Votes
85+
86+ let allVotes = round1Votes ++ round2Votes
87+ numVotes :: Int
7588 numVotes = length allVotes
7689 annotateShow numVotes
7790
@@ -86,13 +99,35 @@ hprop_ledger_events_propose_new_constitution = integrationRetryWorkspace 2 "prop
8699 , creationGenesisOptions = def { genesisEpochLength = 200 }
87100 }
88101
102+ liftToIntegration $ createTestnetEnv creationOptions conf
103+
104+ -- Override Conway genesis: raise dvtUpdateToConstitution to 51% so a minority of yes-votes
105+ -- cannot trigger ratification, and extend govActionLifetime so the proposal survives long
106+ -- enough for the pulsing snapshot to refresh with votes.
107+ let conwayGenesisFile = tempAbsBasePath' </> defaultGenesisFilepath ConwayEra
108+ H. rewriteJsonFile conwayGenesisFile $ \ conwayGenesis ->
109+ let upPParams = L. cgUpgradePParams conwayGenesis
110+ in conwayGenesis
111+ { L. cgUpgradePParams = upPParams
112+ { L. ucppDRepVotingThresholds =
113+ L. ucppDRepVotingThresholds upPParams
114+ & L. dvtUpdateToConstitutionL .~ unsafeBoundedRational (51 % 100 )
115+ , L. ucppGovActionLifetime = EpochInterval 10
116+ }
117+ }
118+
119+ -- Rehash: the node validates genesis file hashes against configuration.yaml
120+ conwayGenesisHash <- getShelleyGenesisHash conwayGenesisFile " ConwayGenesisHash"
121+ H. rewriteJsonFile (tempAbsBasePath' </> " configuration.yaml" ) $
122+ \ (config :: Aeson. Value ) -> config & Aeson. _Object %~ (conwayGenesisHash <> )
123+
89124 TestnetRuntime
90125 { testnetMagic
91126 , testnetNodes
92127 , wallets= wallet0: wallet1: _
93128 , configurationFile
94129 }
95- <- createAndRunTestnet creationOptions def conf
130+ <- liftToIntegration $ cardanoTestnet (creationNodes creationOptions) def conf
96131
97132 node <- H. headM testnetNodes
98133 poolSprocket1 <- H. noteShow $ nodeSprocket node
@@ -232,69 +267,28 @@ hprop_ledger_events_propose_new_constitution = integrationRetryWorkspace 2 "prop
232267 retryUntilJustM epochStateView (WaitForEpochs $ EpochInterval 1 )
233268 $ maybeExtractGovernanceActionIndex governanceActionTxId <$> getEpochState epochStateView
234269
235- -- Proposal was successfully submitted, now we vote on the proposal and confirm it was ratified
236- voteFiles <- generateVoteFiles execConfig work " vote-files"
237- governanceActionTxId governanceActionIndex
238- [(defaultDRepKeyPair idx, vote) | (vote, idx) <- allVotes]
239-
240- -- Submit votes
241- voteTxBodyFp <- createVotingTxBody execConfig epochStateView sbe work " vote-tx-body"
242- voteFiles wallet0
243-
244- let signingKeys = Some <$> (paymentKeyInfoPair wallet0: (defaultDRepKeyPair . snd <$> allVotes))
245- voteTxFp <- signTx execConfig cEra gov " signed-vote-tx" voteTxBodyFp signingKeys
246-
247- submitTx execConfig cEra voteTxFp
248-
249- waitForGovActionVotes epochStateView (EpochInterval 1 )
250-
251- txId <- H. noteShowM $ retrieveTransactionId execConfig signedProposalTx
252-
253- -- Count votes before checking for ratification. It may happen that the proposal gets removed after
254- -- ratification because of a long waiting time, so we won't be able to access votes.
255- govState <- getGovState epochStateView ceo
256- govActionState <- H. headM $ govState ^. L. cgsProposalsL . L. pPropsL . to toList
257- let votes = govActionState ^. L. gasDRepVotesL . to toList
258-
259- length (filter ((== L. VoteYes ) . snd ) votes) === 4
260- length (filter ((== L. VoteNo ) . snd ) votes) === 3
261- length (filter ((== L. Abstain ) . snd ) votes) === 2
262- length votes === fromIntegral numVotes
263-
264- -- We check that constitution was successfully ratified
265- void . H. leftFailM . H. evalIO . runExceptT $
266- foldEpochState
267- configurationFile
268- socketPath
269- FullValidation
270- (EpochNo 10 )
271- ()
272- (\ epochState _ _ -> foldBlocksCheckConstitutionWasRatified constitutionHash constitutionScriptHash epochState)
273-
274- proposalsJSON :: Aeson. Value <- execCliStdoutToJson execConfig
275- [ eraName, " query" , " proposals" , " --governance-action-tx-id" , prettyShow txId
276- , " --governance-action-index" , " 0"
277- ]
270+ -- Query proposals via CLI before voting to verify proposal structure.
271+ -- Retry until the DRep pulsing snapshot (used by `query proposals`) is refreshed
272+ -- with the newly submitted proposal. The current proposals map is updated immediately, but the
273+ -- pulsing snapshot only picks up new proposals at epoch boundaries.
274+ (proposalsJSON, proposalsArray) <-
275+ retryUntilJustM epochStateView (WaitForEpochs $ EpochInterval 2 ) $ do
276+ json :: Aeson. Value <- execCliStdoutToJson execConfig
277+ [ eraName, " query" , " proposals" , " --governance-action-tx-id" , prettyShow governanceActionTxId
278+ , " --governance-action-index" , " 0"
279+ ]
280+ pure $ do
281+ arr <- json ^? Aeson. _Array
282+ guard (length arr == 1 )
283+ pure (json, arr)
278284
279285 -- Display JSON returned in case of failure
280286 H. note_ $ Text. unpack . decodeUtf8 $ prettyPrintJSON proposalsJSON
281-
282- -- Check that the proposals array has only one element and fetch it
283- proposalsArray <- H. evalMaybe $ proposalsJSON ^? Aeson. _Array
284- length proposalsArray === 1
285287 let proposal = proposalsArray Vector. ! 0
286288
287289 -- Check TxId returned is the same as the one we used
288290 proposalsTxId <- H. evalMaybe $ proposal ^? Aeson. key " actionId" . Aeson. key " txId" . Aeson. _String
289- proposalsTxId === Text. pack (prettyShow txId)
290-
291- -- Check that committeeVotes is an empty object
292- proposalsCommitteeVotes <- H. evalMaybe $ proposal ^? Aeson. key " committeeVotes" . Aeson. _Object
293- proposalsCommitteeVotes === mempty
294-
295- -- Check that dRepVotes has the expected number of votes
296- proposalsDRepVotes <- H. evalMaybe $ proposal ^? Aeson. key " dRepVotes" . Aeson. _Object
297- length proposalsDRepVotes === numVotes
291+ proposalsTxId === Text. pack (prettyShow governanceActionTxId)
298292
299293 -- Fetch proposalProcedure and anchor
300294 proposalsProcedure <- H. evalMaybe $ proposal ^? Aeson. key " proposalProcedure"
@@ -334,9 +328,114 @@ hprop_ledger_events_propose_new_constitution = integrationRetryWorkspace 2 "prop
334328 proposalsTag <- H. evalMaybe $ proposalsProcedure ^? Aeson. key " govAction" . Aeson. key " tag" . Aeson. _String
335329 proposalsTag === " NewConstitution"
336330
337- -- Check the stake pool votes are empty
338- proposalsStakePoolVotes <- H. evalMaybe $ proposal ^? Aeson. key " stakePoolVotes" . Aeson. _Object
339- proposalsStakePoolVotes === mempty
331+ -- Round 1: submit 3 yes votes. Ratio = 3 yes / (3 yes + 6 non-voting) = 33.3%, below 51% threshold.
332+ -- The proposal cannot be ratified, so it persists across epoch boundaries.
333+ do let drepVotes = [(defaultDRepKeyPair idx, vote) | (vote, idx) <- round1Votes]
334+ voteFiles <- generateVoteFiles execConfig work " round1-vote-files"
335+ governanceActionTxId governanceActionIndex drepVotes
336+ voteTxBodyFp <- createVotingTxBody execConfig epochStateView sbe work " round1-vote-tx-body"
337+ voteFiles wallet0
338+ let signingKeys = Some <$> (paymentKeyInfoPair wallet0: (fst <$> drepVotes))
339+ voteTxFp <- signTx execConfig cEra gov " round1-signed-vote-tx" voteTxBodyFp signingKeys
340+ submitTx execConfig cEra voteTxFp
341+
342+ waitForGovActionVotes epochStateView (EpochInterval 1 )
343+
344+ -- Verify votes in ledger state
345+ govState <- getGovState epochStateView ceo
346+ govActionState <- H. headM $ govState ^. L. cgsProposalsL . L. pPropsL . to toList
347+ let votes = govActionState ^. L. gasDRepVotesL . to toList
348+ length (filter ((== L. VoteYes ) . snd ) votes) === 3
349+ length (filter ((== L. VoteNo ) . snd ) votes) === 0
350+ length (filter ((== L. Abstain ) . snd ) votes) === 0
351+ length votes === length round1Votes
352+
353+ -- Verify votes via CLI. The proposal is below the ratification threshold,
354+ -- so it cannot be removed - this query is deterministic.
355+ cliProposal <-
356+ retryUntilJustM epochStateView (WaitForEpochs $ EpochInterval 2 ) $ do
357+ json :: Aeson. Value <- execCliStdoutToJson execConfig
358+ [ eraName, " query" , " proposals" , " --governance-action-tx-id" , prettyShow governanceActionTxId
359+ , " --governance-action-index" , " 0"
360+ ]
361+ pure $ do
362+ arr <- json ^? Aeson. _Array
363+ guard (length arr == 1 )
364+ let p = arr Vector. ! 0
365+ dv <- p ^? Aeson. key " dRepVotes" . Aeson. _Object
366+ guard (length dv == length round1Votes)
367+ pure p
368+
369+ drepVotesJson <- H. evalMaybe $ cliProposal ^? Aeson. key " dRepVotes" . Aeson. _Object
370+ length drepVotesJson === length round1Votes
371+
372+ committeeVotes <- H. evalMaybe $ cliProposal ^? Aeson. key " committeeVotes" . Aeson. _Object
373+ committeeVotes === mempty
374+
375+ stakePoolVotes <- H. evalMaybe $ cliProposal ^? Aeson. key " stakePoolVotes" . Aeson. _Object
376+ stakePoolVotes === mempty
377+
378+ -- Round 2: submit +1 yes, +3 no, +2 abstain. Ratio = (3+1) yes / (9-2 abstain) = 57.1% > 51%
379+ -- (abstainers excluded from denominator), triggers ratification.
380+ do let drepVotes = [(defaultDRepKeyPair idx, vote) | (vote, idx) <- round2Votes]
381+ voteFiles <- generateVoteFiles execConfig work " round2-vote-files"
382+ governanceActionTxId governanceActionIndex drepVotes
383+ voteTxBodyFp <- createVotingTxBody execConfig epochStateView sbe work " round2-vote-tx-body"
384+ voteFiles wallet0
385+ let signingKeys = Some <$> (paymentKeyInfoPair wallet0: (fst <$> drepVotes))
386+ voteTxFp <- signTx execConfig cEra gov " round2-signed-vote-tx" voteTxBodyFp signingKeys
387+ submitTx execConfig cEra voteTxFp
388+
389+ -- Wait for all round 2 votes to appear in the ledger.
390+ -- Cannot use waitForGovActionVotes here: it only checks for ANY votes, so it returns
391+ -- immediately seeing round 1's votes before round 2 votes hit the ledger.
392+ votes <-
393+ retryUntilJustM epochStateView (WaitForEpochs $ EpochInterval 2 ) $ do
394+ govState <- getGovState epochStateView ceo
395+ pure $ do
396+ govActionState <- listToMaybe $ govState ^. L. cgsProposalsL . L. pPropsL . to toList
397+ let vs = govActionState ^. L. gasDRepVotesL . to toList
398+ guard (length vs == numVotes)
399+ pure vs
400+
401+ length (filter ((== L. VoteYes ) . snd ) votes) === 4
402+ length (filter ((== L. VoteNo ) . snd ) votes) === 3
403+ length (filter ((== L. Abstain ) . snd ) votes) === 2
404+ length votes === numVotes
405+
406+ -- Verify all votes via CLI. Retry until the pulsing snapshot reflects the full vote set.
407+ cliProposal <-
408+ retryUntilJustM epochStateView (WaitForEpochs $ EpochInterval 2 ) $ do
409+ json :: Aeson. Value <- execCliStdoutToJson execConfig
410+ [ eraName, " query" , " proposals" , " --governance-action-tx-id" , prettyShow governanceActionTxId
411+ , " --governance-action-index" , " 0"
412+ ]
413+ pure $ do
414+ arr <- json ^? Aeson. _Array
415+ guard (length arr == 1 )
416+ let p = arr Vector. ! 0
417+ dv <- p ^? Aeson. key " dRepVotes" . Aeson. _Object
418+ guard (length dv == numVotes)
419+ pure p
420+
421+ drepVotesJson <- H. evalMaybe $ cliProposal ^? Aeson. key " dRepVotes" . Aeson. _Object
422+ length drepVotesJson === numVotes
423+
424+ committeeVotes <- H. evalMaybe $ cliProposal ^? Aeson. key " committeeVotes" . Aeson. _Object
425+ committeeVotes === mempty
426+
427+ stakePoolVotes <- H. evalMaybe $ cliProposal ^? Aeson. key " stakePoolVotes" . Aeson. _Object
428+ stakePoolVotes === mempty
429+
430+ -- We check that constitution was successfully ratified
431+ void . H. leftFailM . H. evalIO . runExceptT $
432+ foldEpochState
433+ configurationFile
434+ socketPath
435+ FullValidation
436+ (EpochNo 20 )
437+ ()
438+ (\ epochState _ _ -> foldBlocksCheckConstitutionWasRatified constitutionHash constitutionScriptHash epochState)
340439
341440foldBlocksCheckConstitutionWasRatified
342441 :: String -- submitted constitution hash
0 commit comments