Skip to content

Commit 6d00b7e

Browse files
committed
fixyfix
1 parent 599e852 commit 6d00b7e

5 files changed

Lines changed: 159 additions & 34 deletions

File tree

.changes/20260728_cardano_api_vote_key_witness_count.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ kind:
44
- bugfix
55
- compatible
66
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`. Fee estimation also no longer counts the same key twice when it is required by more than one of certificates, withdrawals, extra key witnesses and votes. `estimateTransactionKeyWitnessCount` is now also exported from `Cardano.Api.Experimental`. See [issue #722](https://github.com/IntersectMBO/cardano-api/issues/722).
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`. Fee estimation also no longer counts the same key twice when it is required by more than one of certificates, withdrawals, extra key witnesses and votes. `estimateTransactionKeyWitnessCount` is now also exported from `Cardano.Api.Experimental`. Pool registration certificates now count one key witness for the operator and each owner. See [issue #722](https://github.com/IntersectMBO/cardano-api/issues/722).

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1746,12 +1746,13 @@ handleExUnitsErrors ScriptInvalid failuresMap exUnitsMap
17461746
| null failuresMap = Left TxBodyScriptBadScriptValidity
17471747
| otherwise = Right $ Map.map (\_ -> ExecutionUnits 0 0) failuresMap <> exUnitsMap
17481748

1749-
-- | Provide and approximate count of the key witnesses (i.e. signatures)
1749+
-- | Provide an approximate count of the key witnesses (i.e. signatures)
17501750
-- required for a transaction.
17511751
--
17521752
-- Certificates, withdrawals, extra key witnesses and votes are deduplicated against
1753-
-- each other, mirroring the key hash set the ledger computes, so a key acting in
1754-
-- several of those roles is counted once.
1753+
-- each other, mirroring the key hash set ledger's @getWitsVKeyNeeded@ computes, so a
1754+
-- key acting in several of those roles is counted once. A pool registration
1755+
-- certificate counts one key witness for the operator and one for every owner.
17551756
--
17561757
-- This estimate is not exact and may overestimate the required number of witnesses.
17571758
-- The function makes conservative assumptions, including:
@@ -1810,15 +1811,24 @@ estimateTransactionKeyWitnessCount
18101811
Set.fromList $
18111812
mapMaybe (\(StakeAddress _ credential, _, _) -> credKeyHashWitness credential) withdrawals
18121813

1814+
-- The certificate itself decides who must sign, mirroring ledger's
1815+
-- 'getVKeyWitnessTxCert': a pool registration certificate additionally
1816+
-- requires every owner to sign, not just the operator.
18131817
certificateKeyHashes :: Set (L.KeyHash L.Witness)
18141818
certificateKeyHashes = case txCertificates of
18151819
TxCertificates credWits ->
18161820
obtainCommonConstraints (useEra @era) $
1817-
Set.fromList
1818-
[ keyHash
1819-
| (Exp.Certificate certificate, _) <- toList credWits
1820-
, Just keyHash <- [L.getVKeyWitnessTxCert certificate]
1821-
]
1821+
let
1822+
-- Every owner of a pool registration certificate must also sign.
1823+
ownerKeyHashes :: L.TxCert (LedgerEra era) -> Set (L.KeyHash L.Witness)
1824+
ownerKeyHashes certificate = case certificate of
1825+
L.RegPoolTxCert poolParams -> Set.map asWitness (L.sppOwners poolParams)
1826+
_ -> mempty
1827+
in
1828+
Set.unions
1829+
[ maybe mempty Set.singleton (L.getVKeyWitnessTxCert certificate) <> ownerKeyHashes certificate
1830+
| (Exp.Certificate certificate, _) <- toList credWits
1831+
]
18221832

18231833
voteKeyHashes :: Set (L.KeyHash L.Witness)
18241834
voteKeyHashes = case txVotingProcedures of

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

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -449,12 +449,13 @@ calculateMinTxFee sbe pp utxo txbody keywitcount =
449449
ShelleyTx _ tx ->
450450
L.calcMinFeeTx (toLedgerUTxO sbe utxo) pp tx (fromIntegral keywitcount)
451451

452-
-- | Provide and approximate count of the key witnesses (i.e. signatures)
452+
-- | Provide an approximate count of the key witnesses (i.e. signatures)
453453
-- required for a transaction.
454454
--
455455
-- Certificates, withdrawals, extra key witnesses and votes are deduplicated against
456-
-- each other, mirroring the key hash set the ledger computes, so a key acting in
457-
-- several of those roles is counted once.
456+
-- each other, mirroring the key hash set ledger's @getWitsVKeyNeeded@ computes, so a
457+
-- key acting in several of those roles is counted once. A pool registration
458+
-- certificate counts one key witness for the operator and one for every owner.
458459
--
459460
-- This estimate is not exact and may overestimate the required number of witnesses.
460461
-- The function makes conservative assumptions, including:
@@ -470,6 +471,10 @@ calculateMinTxFee sbe pp utxo txbody keywitcount =
470471
-- missing from that set. Use 'calculateMinTxFee' with a 'UTxO' in hand for an exact
471472
-- count.
472473
--
474+
-- * Counting one witness per genesis key of a pre-Conway update proposal, on top of
475+
-- the deduplicated set, because the actual signers are the genesis delegate keys,
476+
-- which are only known from ledger state.
477+
--
473478
-- TODO: Consider implementing a more precise calculation that leverages the UTXO set
474479
-- to determine which inputs correspond to distinct addresses. Additionally, the
475480
-- estimate can be refined by distinguishing between Shelley and Byron-style witnesses.
@@ -512,16 +517,24 @@ estimateTransactionKeyWitnessCount
512517
Set.fromList $
513518
mapMaybe (\(StakeAddress _ credential, _, _) -> credKeyHashWitness credential) withdrawals
514519

515-
-- The credential paired with a certificate is derived from the certificate itself,
516-
-- so its shape, not the caller-supplied witness, decides who must sign.
520+
-- The certificate itself decides who must sign, mirroring ledger's
521+
-- 'getVKeyWitnessTxCert': a pool registration certificate additionally
522+
-- requires every owner to sign, not just the operator.
517523
certificateKeyHashes :: Set (L.KeyHash L.Witness)
518524
certificateKeyHashes = case txCertificates of
519525
TxCertificatesNone -> mempty
520-
TxCertificates _ credWits ->
521-
Set.fromList $
522-
mapMaybe
523-
(credKeyHashWitness . toShelleyStakeCredential)
524-
[credential | (_, BuildTxWith (Just (credential, _))) <- toList credWits]
526+
TxCertificates sbe credWits ->
527+
shelleyBasedEraConstraints sbe $
528+
Set.unions
529+
[ maybe mempty Set.singleton (L.getVKeyWitnessTxCert certificate) <> ownerKeyHashes certificate
530+
| (Exp.Certificate certificate, _) <- toList credWits
531+
]
532+
533+
-- Every owner of a pool registration certificate must also sign.
534+
ownerKeyHashes :: L.EraTxCert ledgerera => L.TxCert ledgerera -> Set (L.KeyHash L.Witness)
535+
ownerKeyHashes certificate = case certificate of
536+
L.RegPoolTxCert poolParams -> Set.map asWitness (L.sppOwners poolParams)
537+
_ -> mempty
525538

526539
voteKeyHashes :: Set (L.KeyHash L.Witness)
527540
voteKeyHashes = case maybe TxVotingProceduresNone unFeatured txVotingProcedures of

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

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ import Cardano.Slotting.EpochInfo qualified as Slotting
2626
import Cardano.Slotting.Slot qualified as Slotting
2727
import Cardano.Slotting.Time qualified as Slotting
2828

29+
import Data.Default (def)
2930
import Data.Foldable (toList)
3031
import Data.Map.Strict qualified as Map
3132
import Data.Sequence.Strict qualified as Seq
33+
import Data.Set qualified as Set
3234
import Data.Time.Clock.POSIX qualified as Time
3335
import Lens.Micro
3436

@@ -43,7 +45,7 @@ import Test.Gen.Cardano.Api.Typed
4345

4446
import Test.Cardano.Api.Experimental (exampleProtocolParams, exampleProtocolParamsEra)
4547

46-
import Hedgehog (Gen, Property)
48+
import Hedgehog (Gen, Property, (===))
4749
import Hedgehog qualified as H
4850
import Hedgehog.Extras qualified as H
4951
import Hedgehog.Gen qualified as Gen
@@ -79,6 +81,9 @@ tests =
7981
, testProperty
8082
"cross-role key witness dedupe"
8183
prop_estimateTransactionKeyWitnessCount_dedupes_across_roles
84+
, testProperty
85+
"pool registration counts operator and every owner"
86+
prop_estimateTransactionKeyWitnessCount_counts_pool_owners
8287
]
8388
, testGroup
8489
"createCompatibleTx"
@@ -583,7 +588,7 @@ prop_calcMinFeeRecursive_well_funded_succeeds = H.property $ do
583588
(const False)
584589
utxo
585590
(resultLedgerTx ^. L.bodyTxL)
586-
balance H.=== mempty
591+
balance === mempty
587592

588593
-- | Like 'prop_calcMinFeeRecursive_well_funded_succeeds' but the UTxO and
589594
-- output carry native tokens. Verifies that surplus tokens are correctly
@@ -603,7 +608,7 @@ prop_calcMinFeeRecursive_well_funded_multi_asset = H.property $ do
603608
(const False)
604609
utxo
605610
(resultLedgerTx ^. L.bodyTxL)
606-
balance H.=== mempty
611+
balance === mempty
607612

608613
-- | 'calcMinFeeRecursive' is idempotent: applying it to its own result
609614
-- yields the same 'UnsignedTx'. This confirms the fee has reached a
@@ -617,7 +622,7 @@ prop_calcMinFeeRecursive_fee_fixpoint = H.property $ do
617622
secondResult <-
618623
H.leftFail $
619624
Exp.calcMinFeeRecursive changeAddr resultTx utxo exampleProtocolParams mempty mempty 0
620-
resultTx H.=== secondResult
625+
resultTx === secondResult
621626

622627
-- | When the outputs exceed the UTxO value the function returns
623628
-- 'Left (NotEnoughAdaForNewOutput _)' with a negative deficit coin.
@@ -670,7 +675,7 @@ prop_calcMinFeeRecursive_no_tx_outs = H.property $ do
670675
Exp.calcMinFeeRecursive changeAddr unsignedTx utxo exampleProtocolParams mempty mempty 0
671676
let outs = toList $ resultLedgerTx ^. L.bodyTxL . L.outputsTxBodyL
672677
-- The result should have exactly one output (the change output)
673-
length outs H.=== 1
678+
length outs === 1
674679

675680
-- | When the surplus is just barely enough to cover the initial fee but not
676681
-- the higher fee after adding a change output, the change output balance
@@ -709,7 +714,7 @@ prop_calcMinFeeRecursive_withdrawal_funded_succeeds = H.property $ do
709714
(const False)
710715
utxo
711716
(resultLedgerTx ^. L.bodyTxL)
712-
balance H.=== mempty
717+
balance === mempty
713718

714719
-- | When the input is tiny (below the minimum fee) and the withdrawal only
715720
-- covers @output - input@ exactly, 'calcMinFeeRecursive' must fail
@@ -798,7 +803,7 @@ prop_evaluateSignedTx_balanced_mempty = H.property $ do
798803
mempty
799804
utxo
800805
signedTx
801-
Exp.txEvalBalance result H.=== mempty
806+
Exp.txEvalBalance result === mempty
802807

803808
-- | Evaluate a simple signed transaction, returning the result and UTxO.
804809
evalSimpleTx
@@ -841,7 +846,7 @@ prop_substituteExecutionUnits_preserves_certs = H.property $ do
841846
Exp.defaultTxBodyContent
842847
& Exp.setTxCertificates inputCerts
843848
result <- H.evalEither $ Exp.substituteExecutionUnits Map.empty txBodyContent
844-
Exp.txCertificates result H.=== inputCerts
849+
Exp.txCertificates result === inputCerts
845850

846851
-- | 'collectTxBodyScriptWitnesses' must return exactly the script-witnessed
847852
-- certs (1 simple script witness in the generator) and must not include
@@ -854,7 +859,7 @@ prop_collectTxBodyScriptWitnesses_ignores_unwitnessed_certs = H.property $ do
854859
Exp.defaultTxBodyContent
855860
& Exp.setTxCertificates inputCerts
856861
scriptWitnesses = Exp.collectTxBodyScriptWitnesses txBodyContent
857-
length scriptWitnesses H.=== 1
862+
length scriptWitnesses === 1
858863

859864
-- | 'createCompatibleTx' must include every certificate (both witnessed and
860865
-- unwitnessed) in the resulting ledger transaction body. This ensures that
@@ -867,7 +872,7 @@ prop_createCompatibleTx_preserves_all_certs = H.property $ do
867872
Api.ShelleyTx _ ledgerTx <-
868873
H.evalEither $ createCompatibleTx sbe [] [] mempty 0 (NoPParamsUpdate sbe) NoVotes inputCerts
869874
let bodyCerts = ledgerTx ^. L.bodyTxL . L.certsTxBodyL
870-
Seq.length bodyCerts H.=== expectedCount
875+
Seq.length bodyCerts === expectedCount
871876

872877
-- | Regression test for: a key-credentialed voter (e.g. a key-hash DRep)
873878
-- requires a VKey witness to satisfy the ledger, but
@@ -889,7 +894,7 @@ prop_estimateTransactionKeyWitnessCount_counts_vote_key_witnesses = H.property $
889894
Exp.defaultTxBodyContent
890895
& Exp.setTxVotingProcedures txVotingProcedures
891896
keyWitnessCount = Exp.estimateTransactionKeyWitnessCount @Exp.ConwayEra txBodyContent
892-
keyWitnessCount H.=== fromIntegral expectedKeyWitnessCount
897+
keyWitnessCount === fromIntegral expectedKeyWitnessCount
893898

894899
-- | Cross-role dedupe: a stake key that both withdraws rewards and is
895900
-- deregistered by a certificate in the same transaction needs one key
@@ -919,11 +924,60 @@ prop_estimateTransactionKeyWitnessCount_dedupes_across_roles = H.property $ do
919924
& Exp.setTxExtraKeyWits (Exp.TxExtraKeyWitnesses [extraPaymentKeyHash])
920925
-- the deregistered key is the withdrawing key: counted once, plus the extra key witness
921926
Exp.estimateTransactionKeyWitnessCount @Exp.ConwayEra (contentWithCertFor stakeCredential)
922-
H.=== 2
927+
=== 2
923928
-- an unrelated deregistered key: nothing collides
924929
Exp.estimateTransactionKeyWitnessCount @Exp.ConwayEra
925930
(contentWithCertFor (Api.StakeCredentialByKey unrelatedStakeKeyHash))
926-
H.=== 3
931+
=== 3
932+
933+
-- | A pool registration certificate requires a signature from the operator
934+
-- and from every owner, mirroring ledger's 'getShelleyWitsVKeyNeeded'. An
935+
-- owner who also withdraws rewards in the same transaction still counts once.
936+
prop_estimateTransactionKeyWitnessCount_counts_pool_owners :: Property
937+
prop_estimateTransactionKeyWitnessCount_counts_pool_owners = H.property $ do
938+
operatorKeyHash <- H.forAll $ genVerificationKeyHash Api.AsStakePoolKey
939+
withdrawingStakeKeyHash <- H.forAll $ genVerificationKeyHash Api.AsStakeKey
940+
otherOwnerStakeKeyHash <- H.forAll $ genVerificationKeyHash Api.AsStakeKey
941+
unrelatedOwnerStakeKeyHash <- H.forAll $ genVerificationKeyHash Api.AsStakeKey
942+
Api.VrfKeyHash vrfKeyHash <- H.forAll $ genVerificationKeyHash Api.AsVrfKey
943+
let stakeAddress = Api.makeStakeAddress Api.Mainnet (Api.StakeCredentialByKey withdrawingStakeKeyHash)
944+
poolParamsWithOwners owners =
945+
L.StakePoolParams
946+
{ L.sppId = Api.unStakePoolKeyHash operatorKeyHash
947+
, L.sppVrf = L.toVRFVerKeyHash vrfKeyHash
948+
, L.sppPledge = L.Coin 0
949+
, L.sppCost = L.Coin 0
950+
, L.sppMargin = minBound
951+
, L.sppAccountAddress = def
952+
, L.sppOwners = owners
953+
, L.sppRelays = mempty
954+
, L.sppMetadata = L.SNothing
955+
}
956+
registrationCertFor owners =
957+
Exp.Certificate $ L.RegPoolTxCert (poolParamsWithOwners owners)
958+
contentWithOwners owners =
959+
Exp.defaultTxBodyContent
960+
& Exp.setTxWithdrawals
961+
(Exp.TxWithdrawals [(stakeAddress, L.Coin 0, Exp.AnyKeyWitnessPlaceholder)])
962+
& Exp.setTxCertificates
963+
( Exp.mkTxCertificates
964+
Exp.ConwayEra
965+
[(registrationCertFor owners, Exp.AnyKeyWitnessPlaceholder)]
966+
)
967+
-- the withdrawing key is also a pool owner: counted once, plus the operator and the other owner
968+
Exp.estimateTransactionKeyWitnessCount @Exp.ConwayEra
969+
( contentWithOwners
970+
(Set.fromList [Api.unStakeKeyHash withdrawingStakeKeyHash, Api.unStakeKeyHash otherOwnerStakeKeyHash])
971+
)
972+
=== 3
973+
-- owners disjoint from the withdrawing key: nothing collides, so all four keys count
974+
Exp.estimateTransactionKeyWitnessCount @Exp.ConwayEra
975+
( contentWithOwners
976+
( Set.fromList
977+
[Api.unStakeKeyHash otherOwnerStakeKeyHash, Api.unStakeKeyHash unrelatedOwnerStakeKeyHash]
978+
)
979+
)
980+
=== 4
927981

928982
-- ---------------------------------------------------------------------------
929983
-- Shared cert generators
@@ -1042,7 +1096,7 @@ genVotingProceduresWithKeyWitnessCount = do
10421096
where
10431097
votingProcedure = L.VotingProcedure{L.vProcVote = L.VoteYes, L.vProcAnchor = L.SNothing}
10441098

1045-
-- \| One entry per action id the voter votes on, witnessed identically each
1099+
-- One entry per action id the voter votes on, witnessed identically each
10461100
-- time - 'Exp.mkTxVotingProcedures' merges same-voter entries with
10471101
-- disjoint action ids and 'Map.union's their (identical) witnesses, so the
10481102
-- resulting witness map still has exactly one entry per voter.
@@ -1064,7 +1118,7 @@ genVotingProceduresWithKeyWitnessCount = do
10641118
(L.GovActionId . Api.toShelleyTxId <$> genTxId)
10651119
<*> (L.GovActionIx <$> Gen.word16 (Range.linear 0 5))
10661120

1067-
-- \| A random non-empty subset of the pool - the source of the same voter
1121+
-- A random non-empty subset of the pool - the source of the same voter
10681122
-- voting on several action ids.
10691123
genVotedActionIds :: [L.GovActionId] -> Gen [L.GovActionId]
10701124
genVotedActionIds actionIdPool = do

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import Cardano.Api
1616
import Cardano.Api.Experimental qualified as Exp
1717
import Cardano.Api.Ledger qualified as L
1818

19+
import Data.Default (def)
1920
import Data.Maybe (isJust)
2021
import Data.Set (Set)
2122
import Data.Set qualified as Set
@@ -277,6 +278,50 @@ prop_estimateTransactionKeyWitnessCount_dedupes_across_roles = H.property $ do
277278
estimateTransactionKeyWitnessCount (contentWithCertFor (StakeCredentialByKey unrelatedStakeKeyHash))
278279
=== 3
279280

281+
-- | A pool registration certificate requires a signature from the operator
282+
-- and from every owner, mirroring ledger's 'getShelleyWitsVKeyNeeded'. An
283+
-- owner who also withdraws rewards in the same transaction still counts once.
284+
prop_estimateTransactionKeyWitnessCount_counts_pool_owners :: Property
285+
prop_estimateTransactionKeyWitnessCount_counts_pool_owners = H.property $ do
286+
let sbe = ShelleyBasedEraConway
287+
operatorKeyHash <- H.forAll $ genVerificationKeyHash AsStakePoolKey
288+
withdrawingStakeKeyHash <- H.forAll $ genVerificationKeyHash AsStakeKey
289+
otherOwnerStakeKeyHash <- H.forAll $ genVerificationKeyHash AsStakeKey
290+
unrelatedOwnerStakeKeyHash <- H.forAll $ genVerificationKeyHash AsStakeKey
291+
VrfKeyHash vrfKeyHash <- H.forAll $ genVerificationKeyHash AsVrfKey
292+
let stakeAddress = makeStakeAddress Mainnet (StakeCredentialByKey withdrawingStakeKeyHash)
293+
poolParamsWithOwners owners =
294+
L.StakePoolParams
295+
{ L.sppId = unStakePoolKeyHash operatorKeyHash
296+
, L.sppVrf = L.toVRFVerKeyHash vrfKeyHash
297+
, L.sppPledge = L.Coin 0
298+
, L.sppCost = L.Coin 0
299+
, L.sppMargin = minBound
300+
, L.sppAccountAddress = def
301+
, L.sppOwners = owners
302+
, L.sppRelays = mempty
303+
, L.sppMetadata = L.SNothing
304+
}
305+
registrationCertFor owners =
306+
Exp.Certificate $ L.RegPoolTxCert (poolParamsWithOwners owners)
307+
contentWithOwners owners =
308+
setTxWithdrawals
309+
(TxWithdrawals sbe [(stakeAddress, L.Coin 0, BuildTxWith (KeyWitness KeyWitnessForStakeAddr))])
310+
. setTxCertificates (mkTxCertificates sbe [(registrationCertFor owners, Nothing)])
311+
$ defaultTxBodyContent sbe
312+
-- the withdrawing key is also a pool owner: counted once, plus the operator and the other owner
313+
estimateTransactionKeyWitnessCount
314+
( contentWithOwners
315+
(Set.fromList [unStakeKeyHash withdrawingStakeKeyHash, unStakeKeyHash otherOwnerStakeKeyHash])
316+
)
317+
=== 3
318+
-- owners disjoint from the withdrawing key: nothing collides, so all four keys count
319+
estimateTransactionKeyWitnessCount
320+
( contentWithOwners
321+
(Set.fromList [unStakeKeyHash otherOwnerStakeKeyHash, unStakeKeyHash unrelatedOwnerStakeKeyHash])
322+
)
323+
=== 4
324+
280325
tests :: TestTree
281326
tests =
282327
testGroup
@@ -297,4 +342,7 @@ tests =
297342
, testProperty
298343
"cross-role key witness dedupe"
299344
prop_estimateTransactionKeyWitnessCount_dedupes_across_roles
345+
, testProperty
346+
"pool registration counts operator and every owner"
347+
prop_estimateTransactionKeyWitnessCount_counts_pool_owners
300348
]

0 commit comments

Comments
 (0)