Skip to content

Commit 83353dd

Browse files
authored
Merge pull request #6601 from IntersectMBO/mgalazyn/test/fix-flaky-propose-new-constitution
Fix flaky ProposeNewConstitution test
2 parents 4fa3e6c + 4f64bc1 commit 83353dd

3 files changed

Lines changed: 170 additions & 66 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,5 @@ cardano-tracer/cardano-tracer-test
7777
.idea/
7878

7979
.codex
80+
81+
.serena/
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
### Tests
2+
3+
- Fixed flaky `ProposeNewConstitution` test by setting a meaningful DRep voting threshold and splitting voting into two rounds, so the proposal persists long enough for deterministic assertions.

cardano-testnet/test/cardano-testnet-test/Cardano/Testnet/Test/Gov/ProposeNewConstitution.hs

Lines changed: 165 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import Cardano.Api.Experimental (Some (..), obtainCommonConstraints)
1313
import Cardano.Api.Ledger (EpochInterval (..))
1414

1515
import qualified Cardano.Crypto.Hash as L
16+
import qualified Cardano.Ledger.Conway.Genesis as L
1617
import qualified Cardano.Ledger.Conway.Governance as L
1718
import qualified Cardano.Ledger.Conway.Governance as Ledger
19+
import qualified Cardano.Ledger.Conway.PParams as L
1820
import qualified Cardano.Ledger.Hashes as L
1921
import qualified Cardano.Ledger.Shelley.LedgerState as L
2022
import Cardano.Testnet
@@ -28,6 +30,7 @@ import qualified Data.Aeson.Lens as Aeson
2830
import Data.Default.Class
2931
import Data.Maybe
3032
import Data.Maybe.Strict
33+
import Data.Ratio ((%))
3134
import qualified Data.Text as Text
3235
import Data.Text.Encoding (decodeUtf8)
3336
import qualified Data.Vector as Vector
@@ -48,6 +51,7 @@ import Testnet.Process.Cli.Transaction
4851
import Testnet.Process.Run (addEnvVarsToConfig, execCli', mkExecConfig)
4952
import Testnet.Process.RunIO (liftIOAnnotated)
5053
import Testnet.Property.Util (integrationRetryWorkspace)
54+
import Testnet.Start.Cardano (liftToIntegration)
5155
import Testnet.Start.Types
5256
import 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

341440
foldBlocksCheckConstitutionWasRatified
342441
:: String -- submitted constitution hash

0 commit comments

Comments
 (0)