Skip to content

Commit ab0d8fa

Browse files
committed
Add property tests for calcMinFeeRecursive
Three Hedgehog properties in Test.Cardano.Api.Experimental verify the key invariants of the recursive fee calculator: - well-funded transaction always succeeds and produces a positive fee - fee calculation is idempotent (result is a fixed point) - underfunded transaction (outputs exceed inputs) always returns NotEnoughAda with a negative deficit coin Two lovelace-only generators drive the tests: one with generous UTxO funding (5–20 ADA input, 1–3 ADA output) and one where the output deliberately exceeds the input (0.5–2 ADA vs 5–10 ADA).
1 parent 8afd144 commit ab0d8fa

2 files changed

Lines changed: 132 additions & 16 deletions

File tree

  • cardano-api

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

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -660,7 +660,10 @@ evaluateTransactionFee
660660
evaluateTransactionFee pp (UnsignedTx tx) keywitcount byronwitcount refScriptsSize =
661661
L.estimateMinFeeTx pp tx (fromIntegral keywitcount) (fromIntegral byronwitcount) refScriptsSize
662662

663-
newtype RecursiveFeeCalculationError = NotEnoughAda Coin deriving (Show, Eq)
663+
data RecursiveFeeCalculationError
664+
= NotEnoughAda Coin
665+
| NoTxOuts
666+
deriving (Show, Eq)
664667

665668
instance Error RecursiveFeeCalculationError where
666669
prettyError (NotEnoughAda balance) =
@@ -669,13 +672,9 @@ instance Error RecursiveFeeCalculationError where
669672
, pretty balance
670673
, "\nThis means that the transaction does not have enough ada to cover the fees. The usual solution is to provide more inputs, or inputs with more ada."
671674
]
675+
prettyError NoTxOuts =
676+
"The transaction has no outputs. At least one output is required to balance the transaction."
672677

673-
-- The assumption here is that we are spending a single UTxO
674-
-- When the fee changes we must also adjust the outputs.
675-
-- We will make two outputs, one for the change (if any)
676-
-- and one for the destination address.
677-
-- Currently this still overestimates by 2332 lovelace
678-
-- in the case of a simple utxo spending only tx.
679678
calcMinFeeRecursive
680679
:: forall era
681680
. IsEra era
@@ -690,10 +689,10 @@ calcMinFeeRecursive unSignTx@(UnsignedTx ledgerTx) utxo pparams nExtraWitnesses
690689
-- We have reached the minimum fee but there isn't a guarantee that
691690
-- the inputs/outputs are balanced
692691
return unSignTx
693-
| minFee == txBodyFee && txBalanceCoin > 0 =
692+
| minFee == txBodyFee && txBalanceCoin > 0 = do
694693
-- We have a surplus balance so we modify the outputs to include it.
695-
let balancedOuts = balanceTxOuts txBalanceValue unSignTx
696-
updatedTx = UnsignedTx (ledgerTx & L.bodyTxL . L.outputsTxBodyL .~ Seq.fromList balancedOuts)
694+
balancedOuts <- balanceTxOuts txBalanceValue unSignTx
695+
let updatedTx = UnsignedTx (ledgerTx & L.bodyTxL . L.outputsTxBodyL .~ Seq.fromList balancedOuts)
697696
in return updatedTx
698697
| txBalanceCoin < 0 = Left $ NotEnoughAda txBalanceCoin
699698
| otherwise =
@@ -708,13 +707,14 @@ calcMinFeeRecursive unSignTx@(UnsignedTx ledgerTx) utxo pparams nExtraWitnesses
708707
balanceTxOuts
709708
:: L.Value (LedgerEra era)
710709
-> UnsignedTx (LedgerEra era)
711-
-> [L.TxOut (LedgerEra era)]
710+
-> Either RecursiveFeeCalculationError [L.TxOut (LedgerEra era)]
712711
balanceTxOuts txBalance (UnsignedTx tx) =
713712
let outs = toList $ tx ^. L.bodyTxL . L.outputsTxBodyL
714-
split = List.uncons outs
715-
(h, rest) = maybe (error "calcMinFeeRecursive: No outs!") id split
716-
updatedout = h & L.valueTxOutL %~ (<> txBalance)
717-
in updatedout : rest
713+
in case List.uncons outs of
714+
Nothing -> Left NoTxOuts
715+
Just (h, rest) ->
716+
let updatedout = h & L.valueTxOutL %~ (<> txBalance)
717+
in Right $ updatedout : rest
718718

719719
-- Essentially we check for the existence of collateral inputs. If they exist we
720720
-- create a fictitious collateral return output. Why? Because we need to put dummy values

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

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,14 @@ import Data.Time qualified as Time
4444
import Data.Time.Clock.POSIX qualified as Time
4545
import Lens.Micro
4646

47-
import Test.Gen.Cardano.Api.Typed (genTx)
47+
import Test.Gen.Cardano.Api.Typed (genAddressInEra, genTx, genTxIn)
4848

4949
import Hedgehog (Gen, Property)
5050
import Hedgehog qualified as H
5151
import Hedgehog.Extras qualified as H
5252
import Hedgehog.Gen qualified as Gen
5353
import Hedgehog.Internal.Property qualified as H
54+
import Hedgehog.Range qualified as Range
5455
import Test.Tasty (TestTree, testGroup)
5556
import Test.Tasty.Hedgehog (testProperty)
5657

@@ -77,6 +78,18 @@ tests =
7778
, testProperty
7879
"Roundtrip SerialiseAsRawBytes SignedTx"
7980
prop_roundtrip_serialise_as_raw_bytes_signed_tx
81+
, testGroup
82+
"calcMinFeeRecursive"
83+
[ testProperty
84+
"well-funded transaction always succeeds"
85+
prop_calcMinFeeRecursive_well_funded_succeeds
86+
, testProperty
87+
"fee calculation is idempotent"
88+
prop_calcMinFeeRecursive_fee_fixpoint
89+
, testProperty
90+
"underfunded transaction (outputs exceed inputs) always fails"
91+
prop_calcMinFeeRecursive_insufficient_funds
92+
]
8093
]
8194

8295
prop_created_transaction_with_both_apis_are_the_same :: Property
@@ -494,3 +507,106 @@ prop_roundtrip_serialise_as_raw_bytes_signed_tx = H.withTests (H.TestLimit 20) $
494507
signedTx
495508
(Text.decodeUtf8 . Api.serialiseToRawBytesHex)
496509
(first show . Api.deserialiseFromRawBytesHex . Text.encodeUtf8)
510+
511+
-- ---------------------------------------------------------------------------
512+
-- Property tests for calcMinFeeRecursive
513+
-- ---------------------------------------------------------------------------
514+
515+
-- | Generates a simple lovelace-only transaction with generous UTxO funding.
516+
-- UTxO has 5-20 ADA, the single output has 1-3 ADA, leaving 2-19 ADA of
517+
-- surplus that covers any realistic fee.
518+
genFundedSimpleTx
519+
:: Exp.Era era
520+
-> Gen
521+
( Exp.UnsignedTx (Exp.LedgerEra era)
522+
, L.UTxO (Exp.LedgerEra era)
523+
)
524+
genFundedSimpleTx era = do
525+
let sbe = convert era
526+
txIn <- genTxIn
527+
addr <- Api.toShelleyAddr <$> genAddressInEra sbe
528+
fundingCoin <- L.Coin <$> Gen.integral (Range.linear 5_000_000 20_000_000)
529+
sendCoin <- L.Coin <$> Gen.integral (Range.linear 1_000_000 3_000_000)
530+
let ledgerTxIn = Api.toShelleyTxIn txIn
531+
fundingTxOut =
532+
Exp.obtainCommonConstraints era $
533+
L.mkBasicTxOut addr (L.MaryValue fundingCoin mempty)
534+
utxo = L.UTxO $ Map.singleton ledgerTxIn fundingTxOut
535+
sendTxOut =
536+
Exp.obtainCommonConstraints era $
537+
Exp.TxOut $
538+
Exp.obtainCommonConstraints era $
539+
Ledger.mkBasicTxOut addr (L.MaryValue sendCoin mempty)
540+
txBodyContent =
541+
Exp.defaultTxBodyContent
542+
& Exp.setTxIns [(txIn, Exp.AnyKeyWitnessPlaceholder)]
543+
& Exp.setTxOuts [sendTxOut]
544+
& Exp.setTxFee 0
545+
return (Exp.makeUnsignedTx era txBodyContent, utxo)
546+
547+
-- | Generates a simple lovelace-only transaction where the single output
548+
-- (5-10 ADA) greatly exceeds the UTxO funding (0.5-2 ADA).
549+
genUnderfundedTx
550+
:: forall era
551+
. Exp.Era era
552+
-> Gen
553+
( Exp.UnsignedTx (Exp.LedgerEra era)
554+
, L.UTxO (Exp.LedgerEra era)
555+
)
556+
genUnderfundedTx era = do
557+
let sbe = convert era
558+
txIn <- genTxIn
559+
addr <- Api.toShelleyAddr <$> genAddressInEra sbe
560+
fundingCoin <- L.Coin <$> Gen.integral (Range.linear 500_000 2_000_000)
561+
sendCoin <- L.Coin <$> Gen.integral (Range.linear 5_000_000 10_000_000)
562+
let ledgerTxIn = Api.toShelleyTxIn txIn
563+
fundingTxOut =
564+
Exp.obtainCommonConstraints era $
565+
L.mkBasicTxOut addr (L.MaryValue fundingCoin mempty)
566+
utxo = L.UTxO $ Map.singleton ledgerTxIn fundingTxOut
567+
sendTxOut =
568+
Exp.obtainCommonConstraints era $
569+
Exp.TxOut $
570+
Exp.obtainCommonConstraints era $
571+
Ledger.mkBasicTxOut addr (L.MaryValue sendCoin mempty)
572+
txBodyContent =
573+
Exp.defaultTxBodyContent
574+
& Exp.setTxIns [(txIn, Exp.AnyKeyWitnessPlaceholder)]
575+
& Exp.setTxOuts [sendTxOut]
576+
& Exp.setTxFee 0
577+
return (Exp.makeUnsignedTx era txBodyContent, utxo)
578+
579+
-- | A well-funded transaction (UTxO >> output + fee) always produces a
580+
-- successful positive fee calculation.
581+
prop_calcMinFeeRecursive_well_funded_succeeds :: Property
582+
prop_calcMinFeeRecursive_well_funded_succeeds = H.property $ do
583+
(unsignedTx, utxo) <- H.forAll $ genFundedSimpleTx Exp.ConwayEra
584+
case Exp.calcMinFeeRecursive unsignedTx utxo exampleProtocolParams 0 of
585+
Left err -> H.annotateShow err >> H.failure
586+
Right (Exp.UnsignedTx resultLedgerTx) -> do
587+
let resultFee = resultLedgerTx ^. L.bodyTxL . L.feeTxBodyL
588+
H.assert $ resultFee > L.Coin 0
589+
590+
-- | 'calcMinFeeRecursive' is idempotent: applying it to its own result
591+
-- yields the same 'UnsignedTx'. This confirms the fee has reached a
592+
-- fixed point and that any surplus was already distributed to outputs.
593+
prop_calcMinFeeRecursive_fee_fixpoint :: Property
594+
prop_calcMinFeeRecursive_fee_fixpoint = H.property $ do
595+
(unsignedTx, utxo) <- H.forAll $ genFundedSimpleTx Exp.ConwayEra
596+
case Exp.calcMinFeeRecursive unsignedTx utxo exampleProtocolParams 0 of
597+
Left _ -> H.success
598+
Right resultTx -> do
599+
secondResult <-
600+
H.evalEither $
601+
Exp.calcMinFeeRecursive resultTx utxo exampleProtocolParams 0
602+
resultTx H.=== secondResult
603+
604+
-- | When the outputs exceed the UTxO value the function returns
605+
-- 'Left (NotEnoughAda _)' with a negative deficit coin.
606+
prop_calcMinFeeRecursive_insufficient_funds :: Property
607+
prop_calcMinFeeRecursive_insufficient_funds = H.property $ do
608+
(unsignedTx, utxo) <- H.forAll $ genUnderfundedTx Exp.ConwayEra
609+
case Exp.calcMinFeeRecursive unsignedTx utxo exampleProtocolParams 0 of
610+
Left (Exp.NotEnoughAda deficit) -> H.assert $ deficit < L.Coin 0
611+
Left Exp.NoTxOuts -> H.annotate "Unexpected NoTxOuts error" >> H.failure
612+
Right _ -> H.failure

0 commit comments

Comments
 (0)