Skip to content

Commit eae5142

Browse files
committed
Fix witness counting for voting transaction
1 parent 963e0ca commit eae5142

6 files changed

Lines changed: 192 additions & 1 deletion

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
project: cardano-api
2+
pr: 1271
3+
kind:
4+
- bugfix
5+
- compatible
6+
description: |
7+
Fix fee estimation for transactions containing votes: `estimateTransactionKeyWitnessCount` now accounts for the key witnesses required by key-credentialed voters (key-hash DReps, constitutional committee hot keys, and SPOs), so vote-carrying transactions no longer get underestimated fees and fail with `FeeTooSmallUTxO`. `estimateTransactionKeyWitnessCount` is now also exported from `Cardano.Api.Experimental`. See [issue #722](https://github.com/IntersectMBO/cardano-api/issues/722).

cardano-api/src/Cardano/Api/Experimental.hs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ module Cardano.Api.Experimental
4141
, evaluateTransactionFee
4242
, collectTxBodyScriptWitnesses
4343
, substituteExecutionUnits
44+
, estimateTransactionKeyWitnessCount
4445

4546
-- ** Era-related
4647
, BabbageEra

cardano-api/src/Cardano/Api/Experimental/Tx/Internal/Fee.hs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ module Cardano.Api.Experimental.Tx.Internal.Fee
2020
, calcMinFeeRecursive
2121
, collectTxBodyScriptWitnesses
2222
, estimateBalancedTxBody
23+
, estimateTransactionKeyWitnessCount
2324
, evaluateTransaction
2425
, TxEvaluationResult (..)
2526
, evaluateTransactionExecutionUnits
@@ -1775,6 +1776,7 @@ estimateTransactionKeyWitnessCount
17751776
, txWithdrawals
17761777
, txCertificates
17771778
, txProposalProcedures
1779+
, txVotingProcedures
17781780
} =
17791781
fromIntegral $
17801782
sum (map estimateTxInWitnesses txIns)
@@ -1793,6 +1795,10 @@ estimateTransactionKeyWitnessCount
17931795
Just (TxProposalProcedures m) ->
17941796
OMap.size m
17951797
Nothing -> 0
1798+
+ case txVotingProcedures of
1799+
Just (TxVotingProcedures _ voteWits) ->
1800+
length [() | AnyKeyWitnessPlaceholder <- Map.elems voteWits]
1801+
Nothing -> 0
17961802
where
17971803
estimateTxInWitnesses :: (TxIn, AnyWitness (LedgerEra era)) -> Int
17981804
estimateTxInWitnesses (_, AnyKeyWitnessPlaceholder) = 1

cardano-api/src/Cardano/Api/Tx/Internal/Fee.hs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ estimateTransactionKeyWitnessCount
474474
, txWithdrawals
475475
, txCertificates
476476
, txUpdateProposal
477+
, txVotingProcedures
477478
} =
478479
fromIntegral $
479480
sum (map estimateTxInWitnesses txIns)
@@ -498,6 +499,12 @@ estimateTransactionKeyWitnessCount
498499
TxUpdateProposal _ (UpdateProposal updatePerGenesisKey _) ->
499500
Map.size updatePerGenesisKey
500501
_ -> 0
502+
+ case maybe TxVotingProceduresNone unFeatured txVotingProcedures of
503+
TxVotingProceduresNone -> 0
504+
TxVotingProcedures votingProcedures _scriptWitnessMap ->
505+
length $
506+
filter voterRequiresKeyWitness $
507+
Map.keys (L.unVotingProcedures votingProcedures)
501508
where
502509
estimateTxInWitnesses :: (TxIn, BuildTxWith BuildTx (Witness WitCtxTxIn era)) -> Int
503510
estimateTxInWitnesses (_, BuildTxWith (KeyWitness _)) = 1
@@ -520,6 +527,16 @@ estimateTransactionKeyWitnessCount
520527
maxWitnessesInSimpleScript (RequireAnyOf simpleScripts) = maximum $ map maxWitnessesInSimpleScript simpleScripts
521528
maxWitnessesInSimpleScript (RequireMOf n simpleScripts) = sum $ take n $ sortBy (comparing Down) (map maxWitnessesInSimpleScript simpleScripts)
522529

530+
-- Mirrors ledger's 'Cardano.Ledger.Conway.UTxO.voterWitnesses': a
531+
-- committee or DRep voter needs a VKey witness only when its credential
532+
-- is key-based; a stake pool voter is always key-credentialed.
533+
voterRequiresKeyWitness :: L.Voter -> Bool
534+
voterRequiresKeyWitness (L.CommitteeVoter (L.KeyHashObj _)) = True
535+
voterRequiresKeyWitness (L.CommitteeVoter (L.ScriptHashObj _)) = False
536+
voterRequiresKeyWitness (L.DRepVoter (L.KeyHashObj _)) = True
537+
voterRequiresKeyWitness (L.DRepVoter (L.ScriptHashObj _)) = False
538+
voterRequiresKeyWitness (L.StakePoolVoter _) = True
539+
523540
-- ----------------------------------------------------------------------------
524541
-- Script execution units
525542
--

cardano-api/test/cardano-api-test/Test/Cardano/Api/Experimental/Fee.hs

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
{-# LANGUAGE NumericUnderscores #-}
33
{-# LANGUAGE OverloadedStrings #-}
44
{-# LANGUAGE ScopedTypeVariables #-}
5+
{-# LANGUAGE TypeApplications #-}
56
{-# LANGUAGE TypeFamilies #-}
67

78
module Test.Cardano.Api.Experimental.Fee
@@ -36,7 +37,13 @@ import Data.Time.Clock.POSIX qualified as Time
3637
import GHC.Exts (fromList)
3738
import Lens.Micro
3839

39-
import Test.Gen.Cardano.Api.Typed (genAddressInEra, genStakeCredential, genTxIn)
40+
import Test.Gen.Cardano.Api.Typed
41+
( genAddressInEra
42+
, genScriptHash
43+
, genStakeCredential
44+
, genTxIn
45+
, genVerificationKeyHash
46+
)
4047

4148
import Test.Cardano.Api.Experimental (exampleProtocolParams, exampleProtocolParamsEra)
4249

@@ -68,6 +75,12 @@ tests =
6875
"unwitnessed certs produce no script witnesses"
6976
prop_collectTxBodyScriptWitnesses_ignores_unwitnessed_certs
7077
]
78+
, testGroup
79+
"estimateTransactionKeyWitnessCount"
80+
[ testProperty
81+
"counts key witnesses required by key-credentialed voters"
82+
prop_estimateTransactionKeyWitnessCount_counts_vote_key_witnesses
83+
]
7184
, testGroup
7285
"createCompatibleTx"
7386
[ testProperty
@@ -1100,6 +1113,24 @@ prop_createCompatibleTx_preserves_all_certs = H.property $ do
11001113
let bodyCerts = ledgerTx ^. L.bodyTxL . L.certsTxBodyL
11011114
Seq.length bodyCerts H.=== expectedCount
11021115

1116+
-- | Regression test for: a key-credentialed voter (e.g. a key-hash DRep)
1117+
-- requires a VKey witness to satisfy the ledger, but
1118+
-- 'estimateTransactionKeyWitnessCount''s record pattern does not destructure
1119+
-- 'txVotingProcedures' at all, so a vote witnessed by
1120+
-- 'AnyKeyWitnessPlaceholder' contributes zero to the estimate. A transaction
1121+
-- containing only a generated mix of key-credentialed and script-credentialed
1122+
-- votes (and nothing else) must be estimated to need exactly one key witness
1123+
-- per key-credentialed voter - script-credentialed votes must not add to the
1124+
-- count.
1125+
prop_estimateTransactionKeyWitnessCount_counts_vote_key_witnesses :: Property
1126+
prop_estimateTransactionKeyWitnessCount_counts_vote_key_witnesses = H.property $ do
1127+
(txVotingProcedures, expectedKeyWitnessCount) <- H.forAll genVotingProceduresWithKeyWitnessCount
1128+
let txBodyContent =
1129+
Exp.defaultTxBodyContent
1130+
& Exp.setTxVotingProcedures txVotingProcedures
1131+
keyWitnessCount = Exp.estimateTransactionKeyWitnessCount @Exp.ConwayEra txBodyContent
1132+
keyWitnessCount H.=== fromIntegral expectedKeyWitnessCount
1133+
11031134
-- ---------------------------------------------------------------------------
11041135
-- Shared cert generators
11051136
-- ---------------------------------------------------------------------------
@@ -1157,3 +1188,61 @@ genShuffledCertsWithCount = do
11571188
]
11581189
shuffled <- Gen.shuffle allCerts
11591190
pure (shuffled, length shuffled)
1191+
1192+
-- ---------------------------------------------------------------------------
1193+
-- Shared vote generators
1194+
-- ---------------------------------------------------------------------------
1195+
1196+
-- | Generate a 'TxVotingProcedures' whose witness map mixes key-credentialed
1197+
-- voters ('L.DRepVoter'/'L.CommitteeVoter' over a key hash, plus
1198+
-- 'L.StakePoolVoter', which is always key-credentialed - all witnessed by
1199+
-- 'AnyKeyWitnessPlaceholder') with script-credentialed voters
1200+
-- ('L.DRepVoter'/'L.CommitteeVoter' over a script hash, witnessed by a
1201+
-- reference-input simple script - 'L.StakePoolVoter' has no
1202+
-- script-credentialed form). Each bucket draws its hashes via 'Gen.set', so
1203+
-- voters within a bucket never collide as witness-map keys; voters across
1204+
-- buckets can never collide either, since they differ in the 'L.Voter' or
1205+
-- 'L.Credential' constructor regardless of the underlying hash bytes.
1206+
-- Returns the generated voting procedures together with the number of
1207+
-- key-credentialed voters, i.e. the key-witness count
1208+
-- 'estimateTransactionKeyWitnessCount' must report for a transaction
1209+
-- containing only these votes.
1210+
genVotingProceduresWithKeyWitnessCount
1211+
:: Gen (Exp.TxVotingProcedures (Exp.LedgerEra Exp.ConwayEra), Int)
1212+
genVotingProceduresWithKeyWitnessCount = do
1213+
drepKeyHashes <-
1214+
Gen.set (Range.linear 0 5) (Api.unDRepKeyHash <$> genVerificationKeyHash Api.AsDRepKey)
1215+
committeeKeyHashes <-
1216+
Gen.set
1217+
(Range.linear 0 5)
1218+
(Api.unCommitteeHotKeyHash <$> genVerificationKeyHash Api.AsCommitteeHotKey)
1219+
stakePoolKeyHashes <-
1220+
Gen.set (Range.linear 0 5) (Api.unStakePoolKeyHash <$> genVerificationKeyHash Api.AsStakePoolKey)
1221+
drepScriptHashes <- Gen.set (Range.linear 0 5) (Api.toShelleyScriptHash <$> genScriptHash)
1222+
committeeScriptHashes <- Gen.set (Range.linear 0 5) (Api.toShelleyScriptHash <$> genScriptHash)
1223+
refTxIn <- genTxIn
1224+
1225+
let keyVoters =
1226+
[L.DRepVoter (L.KeyHashObj kh) | kh <- toList drepKeyHashes]
1227+
<> [L.CommitteeVoter (L.KeyHashObj kh) | kh <- toList committeeKeyHashes]
1228+
<> [L.StakePoolVoter kh | kh <- toList stakePoolKeyHashes]
1229+
scriptVoters =
1230+
[L.DRepVoter (L.ScriptHashObj sh) | sh <- toList drepScriptHashes]
1231+
<> [L.CommitteeVoter (L.ScriptHashObj sh) | sh <- toList committeeScriptHashes]
1232+
1233+
govActionId =
1234+
L.GovActionId
1235+
(L.TxId (L.unsafeMakeSafeHash "0000000000000000000000000000000000000000000000000000000000000000"))
1236+
(L.GovActionIx 0)
1237+
votingProcedure = L.VotingProcedure{L.vProcVote = L.VoteYes, L.vProcAnchor = L.SNothing}
1238+
scriptWitness = Exp.AnySimpleScriptWitness (Exp.SReferenceScript refTxIn)
1239+
allVoters = keyVoters <> scriptVoters
1240+
votingProcedures =
1241+
L.VotingProcedures $
1242+
Map.fromList [(voter, Map.singleton govActionId votingProcedure) | voter <- allVoters]
1243+
witnessMap =
1244+
Map.fromList $
1245+
[(voter, Exp.AnyKeyWitnessPlaceholder) | voter <- keyVoters]
1246+
<> [(voter, scriptWitness) | voter <- scriptVoters]
1247+
1248+
pure (Exp.TxVotingProcedures votingProcedures witnessMap, length keyVoters)

cardano-api/test/cardano-api-test/Test/Cardano/Api/TxBody.hs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ import Hedgehog
3333
, (===)
3434
)
3535
import Hedgehog qualified as H
36+
import Hedgehog.Extras qualified as H
3637
import Hedgehog.Gen (shuffle)
38+
import Hedgehog.Gen qualified as Gen
39+
import Hedgehog.Range qualified as Range
3740
import Test.Tasty (TestTree, testGroup)
3841
import Test.Tasty.Hedgehog (testProperty)
3942

@@ -147,6 +150,71 @@ prop_simple_script_witness_count = H.property $ do
147150
satisfyScript (RequireMOf n simpleScripts) = shuffle simpleScripts >>= satisfyScript . RequireAllOf . take n
148151
satisfyScript (RequireAnyOf simpleScripts) = satisfyScript (RequireMOf 1 simpleScripts)
149152

153+
-- | Regression test for: a key-credentialed voter (e.g. a key-hash DRep)
154+
-- requires a VKey witness to satisfy the ledger, but the legacy
155+
-- 'estimateTransactionKeyWitnessCount' does not look at 'txVotingProcedures'
156+
-- at all.
157+
--
158+
-- We isolate the vote contribution rather than asserting an absolute count:
159+
-- 'genValidTxBody' may also populate ins/certs/withdrawals/its own votes
160+
-- randomly, so we compare the estimate for a body carrying our generated
161+
-- votes against the /same/ body with the votes field cleared. Everything
162+
-- else is identical on both sides and cancels out exactly, so the delta
163+
-- must equal exactly the number of key-credentialed voters.
164+
prop_estimateTransactionKeyWitnessCount_counts_vote_key_witnesses :: Property
165+
prop_estimateTransactionKeyWitnessCount_counts_vote_key_witnesses = H.property $ do
166+
let sbe = ShelleyBasedEraConway
167+
ceo = ConwayEraOnwardsConway
168+
(_, baseContent) <- H.forAll $ genValidTxBody sbe
169+
(voteEntries, expectedKeyWitnessCount) <- H.forAll $ genVotingProceduresWithKeyWitnessCount ceo
170+
votingProcedures <- H.leftFail $ mkTxVotingProcedures voteEntries
171+
let contentWithoutVotes = setTxVotingProcedures Nothing baseContent
172+
contentWithVotes = setTxVotingProcedures (Just (Featured ceo votingProcedures)) baseContent
173+
estimateTransactionKeyWitnessCount contentWithVotes
174+
=== estimateTransactionKeyWitnessCount contentWithoutVotes + fromIntegral expectedKeyWitnessCount
175+
where
176+
-- Generate a mix of key-credentialed voters (DRep/committee-hot over a
177+
-- key hash, plus stake pool voters, which are always key-credentialed)
178+
-- and script-credentialed voters (DRep/committee-hot over a script hash -
179+
-- stake pool voters have no script-credentialed form). Each bucket draws
180+
-- its hashes via 'Gen.set', so voters within a bucket never collide as
181+
-- 'Map' keys; voters across buckets can never collide either, since they
182+
-- differ in the 'Voter' or 'Credential' constructor regardless of the
183+
-- underlying hash. Every entry is witnessed by 'Nothing': the legacy
184+
-- estimator's vote-counting branch never consults the witness map at all
185+
-- (it only exists for script witnesses, and this function is estimating
186+
-- KEY witnesses), so the witness value is irrelevant here - only the
187+
-- ledger-side 'Voter'/'Credential' constructor drives the count. Returns
188+
-- the voting procedure entries (ready for 'mkTxVotingProcedures') together
189+
-- with the number of key-credentialed voters.
190+
genVotingProceduresWithKeyWitnessCount
191+
:: ConwayEraOnwards era
192+
-> H.Gen ([(VotingProcedures era, Maybe (ScriptWitness WitCtxStake era))], Int)
193+
genVotingProceduresWithKeyWitnessCount ceo = do
194+
drepKeyHashes <- Gen.set (Range.linear 0 3) (unDRepKeyHash <$> genVerificationKeyHash AsDRepKey)
195+
committeeKeyHashes <-
196+
Gen.set (Range.linear 0 3) (unCommitteeHotKeyHash <$> genVerificationKeyHash AsCommitteeHotKey)
197+
stakePoolKeyHashes <-
198+
Gen.set (Range.linear 0 3) (unStakePoolKeyHash <$> genVerificationKeyHash AsStakePoolKey)
199+
drepScriptHashes <- Gen.set (Range.linear 0 3) (toShelleyScriptHash <$> genScriptHash)
200+
committeeScriptHashes <- Gen.set (Range.linear 0 3) (toShelleyScriptHash <$> genScriptHash)
201+
let govActionId =
202+
L.GovActionId
203+
(L.TxId (L.unsafeMakeSafeHash "0000000000000000000000000000000000000000000000000000000000000000"))
204+
(L.GovActionIx 0)
205+
votingProcedureValue = L.VotingProcedure{L.vProcVote = L.VoteYes, L.vProcAnchor = L.SNothing}
206+
keyVoters =
207+
[L.DRepVoter (L.KeyHashObj kh) | kh <- toList drepKeyHashes]
208+
<> [L.CommitteeVoter (L.KeyHashObj kh) | kh <- toList committeeKeyHashes]
209+
<> [L.StakePoolVoter kh | kh <- toList stakePoolKeyHashes]
210+
scriptVoters =
211+
[L.DRepVoter (L.ScriptHashObj sh) | sh <- toList drepScriptHashes]
212+
<> [L.CommitteeVoter (L.ScriptHashObj sh) | sh <- toList committeeScriptHashes]
213+
mkEntry voter =
214+
(singletonVotingProcedures ceo voter govActionId votingProcedureValue, Nothing)
215+
entries = map mkEntry keyVoters <> map mkEntry scriptVoters
216+
pure (entries, length keyVoters)
217+
150218
tests :: TestTree
151219
tests =
152220
testGroup
@@ -161,4 +229,7 @@ tests =
161229
, testProperty
162230
"simple script witness count"
163231
prop_simple_script_witness_count
232+
, testProperty
233+
"vote key witness count"
234+
prop_estimateTransactionKeyWitnessCount_counts_vote_key_witnesses
164235
]

0 commit comments

Comments
 (0)