Skip to content

Commit 9c85210

Browse files
authored
Merge pull request #1106 from IntersectMBO/jordan/introduce-calculate-min-fee-recursive
Introduce recursive minimum fee calculation
2 parents 23b2f6c + 1b5f63a commit 9c85210

4 files changed

Lines changed: 748 additions & 7 deletions

File tree

cardano-api/cardano-api.cabal

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,13 +367,15 @@ test-suite cardano-api-test
367367
cardano-crypto-wrapper:testlib,
368368
cardano-ledger-alonzo,
369369
cardano-ledger-api ^>=1.12.1,
370+
cardano-ledger-babbage,
370371
cardano-ledger-binary,
371372
cardano-ledger-conway,
372373
cardano-ledger-core >=1.14,
373374
cardano-ledger-mary,
374375
cardano-ledger-shelley,
375376
cardano-protocol-tpraos,
376377
cardano-slotting,
378+
cardano-strict-containers,
377379
cborg,
378380
containers,
379381
data-default,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ module Cardano.Api.Experimental
2525
, mkTxCertificates
2626

2727
-- ** Transaction fee related
28+
, FeeCalculationError (..)
29+
, calcMinFeeRecursive
2830
, estimateBalancedTxBody
2931
, evaluateTransactionFee
3032
, collectTxBodyScriptWitnesses

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

Lines changed: 198 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
{-# LANGUAGE TypeApplications #-}
1414

1515
module Cardano.Api.Experimental.Tx.Internal.Fee
16-
( TxBodyErrorAutoBalance (..)
16+
( FeeCalculationError (..)
17+
, TxBodyErrorAutoBalance (..)
1718
, TxFeeEstimationError (..)
1819
, calculateMinimumUTxO
20+
, calcMinFeeRecursive
1921
, collectTxBodyScriptWitnesses
2022
, estimateBalancedTxBody
2123
, evaluateTransactionExecutionUnits
@@ -83,6 +85,7 @@ import Data.Maybe
8385
import Data.OSet.Strict qualified as OSet
8486
import Data.Ord (Down (Down), comparing)
8587
import Data.Ratio
88+
import Data.Sequence.Strict qualified as Seq
8689
import Data.Set (Set)
8790
import Data.Set qualified as Set
8891
import GHC.Exts (IsList (..))
@@ -657,6 +660,200 @@ evaluateTransactionFee
657660
evaluateTransactionFee pp (UnsignedTx tx) keywitcount byronwitcount refScriptsSize =
658661
L.estimateMinFeeTx pp tx (fromIntegral keywitcount) (fromIntegral byronwitcount) refScriptsSize
659662

663+
data FeeCalculationError
664+
= -- | Updating an existing change output resulted in negative ADA.
665+
NotEnoughAdaForChangeOutput Coin
666+
| -- | Creating a new change output would require negative ADA.
667+
NotEnoughAdaForNewOutput Coin
668+
| NonAdaAssetsUnbalanced L.MultiAsset
669+
| -- | @MinUTxONotMet actual required@: an output does not meet the minimum UTxO requirement.
670+
MinUTxONotMet L.Coin L.Coin
671+
| FeeCalculationDidNotConverge
672+
deriving (Show, Eq)
673+
674+
instance Error FeeCalculationError where
675+
prettyError (NotEnoughAdaForChangeOutput balance) =
676+
mconcat
677+
[ "Not enough ADA when updating existing change output. Balance: "
678+
, pretty balance
679+
, "\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."
680+
]
681+
prettyError (NotEnoughAdaForNewOutput balance) =
682+
mconcat
683+
[ "Not enough ADA when creating new change output. Balance: "
684+
, pretty balance
685+
, "\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."
686+
]
687+
prettyError (NonAdaAssetsUnbalanced multiAsset) =
688+
mconcat
689+
[ "Non-ADA assets are unbalanced: "
690+
, pshow multiAsset
691+
, "\nThe transaction inputs and minted values do not match the outputs for one or more native tokens."
692+
]
693+
prettyError (MinUTxONotMet actual required) =
694+
mconcat
695+
[ "An output does not meet the minimum UTxO requirement."
696+
, "\nActual ADA in output: " <> pretty actual
697+
, "\nMinimum required: " <> pretty required
698+
, "\nThe usual solution is to provide more ADA inputs to cover the minimum UTxO for outputs carrying native tokens."
699+
]
700+
prettyError FeeCalculationDidNotConverge =
701+
"Fee calculation did not converge after the maximum number of iterations."
702+
703+
-- | Recursively calculate the minimum fee for a transaction and balance it.
704+
--
705+
-- Starting from the provided transaction, this function iteratively adjusts
706+
-- the fee field and output values until the transaction is fully balanced
707+
-- (i.e. @inputs + mint + withdrawals + refunds = outputs + fee + deposits@
708+
-- for all value components: ADA and every native token).
709+
--
710+
-- Before entering the iterative loop the multi-asset balance is checked.
711+
-- Because fee adjustments only affect ADA, a negative multi-asset balance
712+
-- is unrecoverable and the function returns 'NonAdaAssetsUnbalanced'
713+
-- immediately.
714+
--
715+
-- On each iteration the balance is computed via 'evaluateTransactionBalance'
716+
-- and the minimum fee via @calcMinFeeTx@. The function then proceeds based
717+
-- on the following cases, evaluated in order:
718+
--
719+
-- * __Case 1 – Fee converged, balance is zero__: The transaction is fully
720+
-- balanced. Before returning, all outputs are checked against the minimum
721+
-- UTxO requirement ('MinUTxONotMet'). Note: a 'MinUTxONotMet' error at
722+
-- this point typically means that Case 2 distributed surplus multi-assets
723+
-- to an output on a prior iteration but there was not enough ADA surplus
724+
-- to satisfy the increased @coinPerUTxOByte@ requirement for that output.
725+
-- The remedy is to provide additional ADA inputs.
726+
--
727+
-- * __Case 2 – Fee converged, non-zero balance__: There is surplus or
728+
-- deficit ADA, excess multi-assets (e.g. from minting), or both. A new
729+
-- change output is created at the provided change address with the
730+
-- balance and appended to the end of the existing outputs; if a change
731+
-- output already exists it is updated in place. If the resulting change
732+
-- output would have negative ADA, the transaction is unrecoverable and
733+
-- 'NotEnoughAdaForChangeOutput' or 'NotEnoughAdaForNewOutput' is returned. Otherwise the function recurses, because
734+
-- the changed output may alter the transaction size and therefore the
735+
-- required fee, and must also satisfy the minimum UTxO
736+
-- (@coinPerUTxOByte@) constraint.
737+
--
738+
-- * __Case 3 – Fee has not converged__: The fee field is set to the newly
739+
-- computed minimum fee and the function recurses.
740+
--
741+
-- A maximum iteration limit (currently 50) guards against non-termination.
742+
-- In practice convergence occurs within 2–3 iterations.
743+
calcMinFeeRecursive
744+
:: forall era
745+
. IsEra era
746+
=> L.Addr
747+
-- ^ Change address. Any surplus value (ADA and/or native tokens) is
748+
-- sent to a new output at this address, appended at the end of the
749+
-- existing outputs.
750+
-> UnsignedTx (LedgerEra era)
751+
-> L.UTxO (LedgerEra era)
752+
-> L.PParams (LedgerEra era)
753+
-> Set PoolId
754+
-- ^ The set of registered stake pools. Pool registrations for pools
755+
-- already in this set are treated as re-registrations (no deposit
756+
-- required on the produced side).
757+
-> Map StakeCredential L.Coin
758+
-- ^ Deposits for stake credentials being deregistered in this
759+
-- transaction. These are counted as refunds on the consumed side.
760+
-> Map (Ledger.Credential Ledger.DRepRole) L.Coin
761+
-- ^ Deposits for DRep credentials being deregistered in this
762+
-- transaction. These are counted as refunds on the consumed side.
763+
-> Int
764+
-- ^ Number of extra key hashes for native scripts
765+
-> Either FeeCalculationError (UnsignedTx (LedgerEra era))
766+
calcMinFeeRecursive changeAddr unsignedTx utxo pparams poolids stakeDelegDeposits drepDelegDeposits nExtraWitnesses
767+
-- If multi-assets are non-negative initially, they stay non-negative across
768+
-- iterations (only ADA and fee change), so check once upfront.
769+
| multiAssetIsNegative =
770+
Left $ NonAdaAssetsUnbalanced multiAssets
771+
| otherwise =
772+
go maxIterations unsignedTx
773+
where
774+
initialBalance = evaluateTransactionBalance pparams poolids stakeDelegDeposits drepDelegDeposits utxo unsignedTx
775+
multiAssets =
776+
obtainCommonConstraints (useEra @era) $
777+
let L.MaryValue _ ma = initialBalance
778+
in ma
779+
-- Check whether any native token quantity is negative.
780+
-- ADA is zeroed out so it doesn't influence the check.
781+
multiAssetIsNegative =
782+
obtainCommonConstraints (useEra @era) $
783+
not (L.pointwise (>=) (L.MaryValue (L.Coin 0) multiAssets) mempty)
784+
maxIterations :: Int
785+
maxIterations = 50
786+
787+
go
788+
:: Int
789+
-> UnsignedTx (LedgerEra era)
790+
-> Either FeeCalculationError (UnsignedTx (LedgerEra era))
791+
go 0 _ = Left FeeCalculationDidNotConverge
792+
go n unSignTx@(UnsignedTx ledgerTx)
793+
| minFee == txBodyFee && L.isZero txBalanceValue = do
794+
-- Case 1
795+
let outs = toList $ ledgerTx ^. L.bodyTxL . L.outputsTxBodyL
796+
mapM_ (checkOutputMinUTxO pparams) outs
797+
return unSignTx
798+
| minFee == txBodyFee = do
799+
-- Case 2
800+
balancedOuts <- balanceTxOuts @era changeAddr txBalanceValue unSignTx
801+
let updatedTx = UnsignedTx (ledgerTx & L.bodyTxL . L.outputsTxBodyL .~ balancedOuts)
802+
go (n - 1) updatedTx
803+
| otherwise =
804+
-- Case 3
805+
let newTx = UnsignedTx (ledgerTx & L.bodyTxL . L.feeTxBodyL .~ minFee)
806+
in go (n - 1) newTx
807+
where
808+
minFee = obtainCommonConstraints (useEra @era) $ L.calcMinFeeTx utxo pparams ledgerTx nExtraWitnesses
809+
txBodyFee = ledgerTx ^. L.bodyTxL . L.feeTxBodyL
810+
txBalanceValue =
811+
evaluateTransactionBalance pparams poolids stakeDelegDeposits drepDelegDeposits utxo unSignTx
812+
813+
checkOutputMinUTxO
814+
:: forall era
815+
. IsEra era
816+
=> Ledger.PParams (LedgerEra era)
817+
-> L.TxOut (LedgerEra era)
818+
-> Either FeeCalculationError ()
819+
checkOutputMinUTxO pp out =
820+
obtainCommonConstraints (useEra @era) $
821+
let txout = TxOut out
822+
in case checkMinUTxOValue pp txout of
823+
Right () -> Right ()
824+
Left (TxOut offending, minRequired) ->
825+
Left $ MinUTxONotMet (offending ^. L.coinTxOutL) minRequired
826+
827+
balanceTxOuts
828+
:: forall era
829+
. HasCallStack
830+
=> IsEra era
831+
=> L.Addr
832+
-> L.Value (LedgerEra era)
833+
-> UnsignedTx (LedgerEra era)
834+
-> Either FeeCalculationError (Seq.StrictSeq (L.TxOut (LedgerEra era)))
835+
balanceTxOuts changeAddr txBalance (UnsignedTx tx) =
836+
obtainCommonConstraints (useEra @era) $
837+
let outs = tx ^. L.bodyTxL . L.outputsTxBodyL
838+
in case outs of
839+
rest Seq.:|> lastOut
840+
| lastOut ^. L.addrTxOutL == changeAddr ->
841+
-- Update existing change output in place.
842+
-- We compute the new value before writing it into the TxOut,
843+
-- because the ledger's TxOut setter throws an exception on
844+
-- negative values.
845+
let newValue = (lastOut ^. L.valueTxOutL) <> txBalance
846+
changeCoin = L.coin newValue
847+
in if changeCoin < 0
848+
then Left $ NotEnoughAdaForChangeOutput changeCoin
849+
else Right $ rest Seq.:|> (lastOut & L.valueTxOutL .~ newValue)
850+
_ ->
851+
-- Append a new change output
852+
let changeCoin = L.coin txBalance
853+
in if changeCoin < 0
854+
then Left $ NotEnoughAdaForNewOutput changeCoin
855+
else Right $ outs Seq.:|> L.mkBasicTxOut changeAddr txBalance
856+
660857
-- Essentially we check for the existence of collateral inputs. If they exist we
661858
-- create a fictitious collateral return output. Why? Because we need to put dummy values
662859
-- to get a fee estimate (i.e we overestimate the fee). The required collateral depends

0 commit comments

Comments
 (0)