Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cardano-api/cardano-api.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -367,13 +367,15 @@ test-suite cardano-api-test
cardano-crypto-wrapper:testlib,
cardano-ledger-alonzo,
cardano-ledger-api ^>=1.12.1,
cardano-ledger-babbage,
cardano-ledger-binary,
cardano-ledger-conway,
cardano-ledger-core >=1.14,
cardano-ledger-mary,
cardano-ledger-shelley,
cardano-protocol-tpraos,
cardano-slotting,
cardano-strict-containers,
cborg,
containers,
data-default,
Expand Down
2 changes: 2 additions & 0 deletions cardano-api/src/Cardano/Api/Experimental.hs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ module Cardano.Api.Experimental
, mkTxCertificates

-- ** Transaction fee related
, FeeCalculationError (..)
, calcMinFeeRecursive
, estimateBalancedTxBody
, evaluateTransactionFee
, collectTxBodyScriptWitnesses
Expand Down
194 changes: 192 additions & 2 deletions cardano-api/src/Cardano/Api/Experimental/Tx/Internal/Fee.hs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
{-# LANGUAGE TypeApplications #-}

module Cardano.Api.Experimental.Tx.Internal.Fee
( TxBodyErrorAutoBalance (..)
( FeeCalculationError (..)
, TxBodyErrorAutoBalance (..)
, TxFeeEstimationError (..)
, calculateMinimumUTxO
, calcMinFeeRecursive
, collectTxBodyScriptWitnesses
, estimateBalancedTxBody
, evaluateTransactionExecutionUnits
Expand Down Expand Up @@ -83,11 +85,12 @@ import Data.Maybe
import Data.OSet.Strict qualified as OSet
import Data.Ord (Down (Down), comparing)
import Data.Ratio
import Data.Sequence.Strict qualified as Seq
import Data.Set (Set)
import Data.Set qualified as Set
import GHC.Exts (IsList (..))
import GHC.Stack
import Lens.Micro ((.~), (^.))
import Lens.Micro ((%~), (.~), (^.))
import Prettyprinter (punctuate)

data TxBodyErrorAutoBalance era
Expand Down Expand Up @@ -657,6 +660,193 @@ evaluateTransactionFee
evaluateTransactionFee pp (UnsignedTx tx) keywitcount byronwitcount refScriptsSize =
L.estimateMinFeeTx pp tx (fromIntegral keywitcount) (fromIntegral byronwitcount) refScriptsSize

data FeeCalculationError
= NotEnoughAda Coin
| NonAdaAssetsUnbalanced L.MultiAsset
| -- | @MinUTxONotMet actual required@: an output does not meet the minimum UTxO requirement.
MinUTxONotMet L.Coin L.Coin
| FeeCalculationDidNotConverge
deriving (Show, Eq)

instance Error FeeCalculationError where
prettyError (NotEnoughAda balance) =
mconcat
[ "The transaction balance is negative: "
, pretty balance
, "\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."
Comment thread
Jimbo4350 marked this conversation as resolved.
]
prettyError (NonAdaAssetsUnbalanced multiAsset) =
mconcat
[ "Non-ADA assets are unbalanced: "
, pshow multiAsset
, "\nThe transaction inputs and minted values do not match the outputs for one or more native tokens."
]
prettyError (MinUTxONotMet actual required) =
mconcat
[ "An output does not meet the minimum UTxO requirement."
, "\nActual ADA in output: " <> pretty actual
, "\nMinimum required: " <> pretty required
, "\nThe usual solution is to provide more ADA inputs to cover the minimum UTxO for outputs carrying native tokens."
]
prettyError FeeCalculationDidNotConverge =
"Fee calculation did not converge after the maximum number of iterations."

-- | Recursively calculate the minimum fee for a transaction and balance it.
--
-- Starting from the provided transaction, this function iteratively adjusts
-- the fee field and output values until the transaction is fully balanced
-- (i.e. @inputs + mint + withdrawals + refunds = outputs + fee + deposits@
-- for all value components: ADA and every native token).
--
-- On each iteration the balance is computed via 'evaluateTransactionBalance'
-- and the minimum fee via @calcMinFeeTx@. The function then proceeds based
-- on the following cases, evaluated in order:
--
-- * __Case 1 – Negative multi-asset balance__: The outputs demand more of a
-- native token than is available from inputs and minting. This is
-- unrecoverable because fee adjustments only affect ADA — they cannot
-- change the multi-asset balance. Remedy: provide additional inputs
-- containing the deficit tokens, mint the missing amount, or reduce the
-- token quantities in the outputs.
-- Returns 'NonAdaAssetsUnbalanced'.
--
-- * __Case 2 – Fee converged, balance is zero__: The transaction is fully
-- balanced. Before returning, all outputs are checked against the minimum
-- UTxO requirement ('MinUTxONotMet'). Note: a 'MinUTxONotMet' error at
-- this point typically means that Case 3 distributed surplus multi-assets
-- to an output on a prior iteration but there was not enough ADA surplus
-- to satisfy the increased @coinPerUTxOByte@ requirement for that output.
-- The remedy is to provide additional ADA inputs.
--
-- * __Case 3 – Fee converged, non-zero balance__: There is surplus or
-- deficit ADA, excess multi-assets (e.g. from minting), or both. A new
-- change output is created at the provided change address with the
-- balance and appended to the end of the existing outputs; if a change
-- output already exists it is updated in place. If the resulting change
-- output would have negative ADA, the transaction is unrecoverable and
-- 'NotEnoughAda' is returned. Otherwise the function recurses, because
-- the changed output may alter the transaction size and therefore the
-- required fee, and must also satisfy the minimum UTxO
-- (@coinPerUTxOByte@) constraint.
--
-- * __Case 4 – Fee has not converged__: The fee field is set to the newly
-- computed minimum fee and the function recurses.
--
-- A maximum iteration limit (currently 50) guards against non-termination.
-- In practice convergence occurs within 2–3 iterations.
calcMinFeeRecursive
:: forall era
. IsEra era
=> L.Addr
-- ^ Change address. Any surplus value (ADA and/or native tokens) is
-- sent to a new output at this address, appended at the end of the
-- existing outputs.
-> UnsignedTx (LedgerEra era)
-> L.UTxO (LedgerEra era)
-> L.PParams (LedgerEra era)
-> Set PoolId
-- ^ The set of registered stake pools. Pool registrations for pools
-- already in this set are treated as re-registrations (no deposit
-- required on the produced side).
-> Map StakeCredential L.Coin
-- ^ Deposits for stake credentials being deregistered in this
-- transaction. These are counted as refunds on the consumed side.
-> Map (Ledger.Credential Ledger.DRepRole) L.Coin
-- ^ Deposits for DRep credentials being deregistered in this
-- transaction. These are counted as refunds on the consumed side.
-> Int
-- ^ Number of extra key hashes for native scripts
-> Either FeeCalculationError (UnsignedTx (LedgerEra era))

@carbolymer carbolymer Mar 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would make FeeCalculationError constructors carry HasCallStack at this point. It would make pinpointing of an error easier. For example NotEnoughAda is returned in two cases:

  1. when modifying the output
  2. when adding new output.

So it's not super obvious where did error come from, until you analyse further.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch but I'll modify the names to make it more obvious.

calcMinFeeRecursive changeAddr = go maxIterations
where
maxIterations :: Int
maxIterations = 50

go
:: Int
-> UnsignedTx (LedgerEra era)
-> L.UTxO (LedgerEra era)
-> L.PParams (LedgerEra era)
-> Set PoolId
-> Map StakeCredential L.Coin
-> Map (Ledger.Credential Ledger.DRepRole) L.Coin
-> Int
-> Either FeeCalculationError (UnsignedTx (LedgerEra era))
go 0 _ _ _ _ _ _ _ = Left FeeCalculationDidNotConverge

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new FeeCalculationDidNotConverge branch appears untested by the added properties. Consider adding a focused test that reliably forces non-convergence (or refactor to make maxIterations injectable in tests) so this failure mode is exercised and its error behavior stays stable.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be extremely difficult to construct a transaction that makes the fee calculation oscillate for 50 iterations, if it's even possible with a realistic set of protocol parameters. Injecting maxIterations for the sake of triggering a test case is unnecessary IMO.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot implement your suggestion as a PR

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this actually an error? I think the result of the calculation can still be useful if the fee is somewhat reasonable.

@copilot figure out a definition of a "reasonable fee" which could be implemented here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is a bit hand-wavy, but not too unreasonable #1120

go n unSignTx@(UnsignedTx ledgerTx) utxo pparams poolids stakeDelegDeposits drepDelegDeposits nExtraWitnesses
| multiAssetIsNegative =
-- Case 1
Left $ NonAdaAssetsUnbalanced (getMultiAssets (useEra @era) txBalanceValue)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be iterated on? If I understand correctly it could just be checked once before iterating, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch it does not.

| minFee == txBodyFee && L.isZero txBalanceValue = do
-- Case 2
let outs = toList $ ledgerTx ^. L.bodyTxL . L.outputsTxBodyL
mapM_ (checkOutputMinUTxO pparams) outs
return unSignTx
| minFee == txBodyFee = do
-- Case 3
balancedOuts <- balanceTxOuts @era changeAddr txBalanceValue unSignTx
let updatedTx = UnsignedTx (ledgerTx & L.bodyTxL . L.outputsTxBodyL .~ balancedOuts)
go (n - 1) updatedTx utxo pparams poolids stakeDelegDeposits drepDelegDeposits nExtraWitnesses
| otherwise =
-- Case 4
let newTx = UnsignedTx (ledgerTx & L.bodyTxL . L.feeTxBodyL .~ minFee)
in go (n - 1) newTx utxo pparams poolids stakeDelegDeposits drepDelegDeposits nExtraWitnesses
where
minFee = obtainCommonConstraints (useEra @era) $ L.calcMinFeeTx utxo pparams ledgerTx nExtraWitnesses
txBodyFee = ledgerTx ^. L.bodyTxL . L.feeTxBodyL
txBalanceValue = evaluateTransactionBalance pparams poolids stakeDelegDeposits drepDelegDeposits utxo unSignTx
txBalanceCoin = L.coin txBalanceValue
multiAssetIsNegative =
obtainCommonConstraints (useEra @era) $
not (L.pointwise (>=) txBalanceValue (L.inject txBalanceCoin))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good to explain why this is the same as checking that multi assets (and only multi-assets) are negative, it is not obvious at all

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be more self-documenting to compare only multi-assets to zero

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed 👍


checkOutputMinUTxO
:: forall era
. IsEra era
=> Ledger.PParams (LedgerEra era)
-> L.TxOut (LedgerEra era)
-> Either FeeCalculationError ()
checkOutputMinUTxO pp out =
obtainCommonConstraints (useEra @era) $
let txout = TxOut out
in case checkMinUTxOValue pp txout of
Right () -> Right ()
Left (TxOut offending, minRequired) ->
Left $ MinUTxONotMet (offending ^. L.coinTxOutL) minRequired

getMultiAssets :: Era era -> L.Value (LedgerEra era) -> L.MultiAsset
getMultiAssets era val = case era of
DijkstraEra -> mempty
Comment thread
Jimbo4350 marked this conversation as resolved.
Outdated
ConwayEra ->
let L.MaryValue _ ma = val
in ma

balanceTxOuts
:: forall era
. IsEra era
=> L.Addr
-> L.Value (LedgerEra era)
-> UnsignedTx (LedgerEra era)
-> Either FeeCalculationError (Seq.StrictSeq (L.TxOut (LedgerEra era)))
balanceTxOuts changeAddr txBalance (UnsignedTx tx) =
obtainCommonConstraints (useEra @era) $
let outs = tx ^. L.bodyTxL . L.outputsTxBodyL
in case outs of
rest Seq.:|> lastOut
| lastOut ^. L.addrTxOutL == changeAddr ->

@carbolymer carbolymer Mar 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if I create outputs: ["addr1", "addr2", "addr3"], and ask to send change to addr1? I think this could be improved to look up the change address here from all addresses in the outputs and modify it.

Unless this is expected to have multiple outputs pointing to the same address. If that is the case, users may be surprised that the last output gets modified with change value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're crystal-ball gazing a bit here. It depends on what we think the user wants to do. A user might create a specific output to addr1 and still want the transaction balance in a separate change output at addr1. My gut says: if a user is calling an automatic balancing function, knowing it creates a change output, and they explicitly construct an output to the same address, they likely want that output to have a specific value, with an additional change output generated separately.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're crystal-ball gazing a bit here. It depends on what we think the user wants to do.

Yes a bit. My point was to just bring this possible use case to your attention.

My gut says: if a user is calling an automatic balancing function, knowing it creates a change output, and they explicitly construct an output to the same address, they likely want that output to have a specific value, with an additional change output generated separately.

Fine by me. 👍🏻 Just wanted to be sure that this is by design.

-- Update existing change output in place
let updatedOut = lastOut & L.valueTxOutL %~ (<> txBalance)
changeCoin = L.coin (updatedOut ^. L.valueTxOutL)
in if changeCoin < 0
then Left $ NotEnoughAda changeCoin
else Right $ rest Seq.:|> updatedOut

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| lastOut ^. L.addrTxOutL == changeAddr ->
-- Update existing change output in place
let updatedOut = lastOut & L.valueTxOutL %~ (<> txBalance)
changeCoin = L.coin (updatedOut ^. L.valueTxOutL)
in if changeCoin < 0
then Left $ NotEnoughAda changeCoin
else Right $ rest Seq.:|> updatedOut
| lastOut ^. L.addrTxOutL == changeAddr ->
let currentValue = lastOut ^. L.valueTxOutL
newValue = currentValue <> txBalance
changeCoin = L.coin newValue
in if changeCoin < 0
then Left $ NotEnoughAda changeCoin
else Right $ rest Seq.:|> (lastOut & L.valueTxOutL .~ newValue)

Apparently setting a negative value into the TxOut (LedgerEra era) crashes, so it needs to be checked before setting it. This was something Claude discovered when investigating a different issue.
It produces this:

│ Illegal Value in TxOut: MaryValue (Coin (-21)) (MultiAsset (fromList []))

See the also Claude generated test here: 842edd7

_ ->
-- Append a new change output
let changeCoin = L.coin txBalance
in if changeCoin < 0
then Left $ NotEnoughAda changeCoin
else Right $ outs Seq.:|> L.mkBasicTxOut changeAddr txBalance

-- Essentially we check for the existence of collateral inputs. If they exist we
-- create a fictitious collateral return output. Why? Because we need to put dummy values
-- to get a fee estimate (i.e we overestimate the fee). The required collateral depends
Expand Down
Loading
Loading