cardano-coin-selection-1.0.0: src/test/Cardano/CoinSelection/FeeSpec.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Cardano.CoinSelection.FeeSpec
( spec
-- Internals
, FeeParameters
, stableEstimator
, valueDependentEstimator
) where
import Prelude hiding
( round )
import Cardano.CoinSelection
( CoinMap (..)
, CoinMapEntry (..)
, CoinSelection (..)
, CoinSelectionAlgorithm (..)
, CoinSelectionResult (..)
, coinMapFromList
, coinMapToList
, sumChange
, sumInputs
, sumOutputs
)
import Cardano.CoinSelection.Algorithm.LargestFirst
( largestFirst )
import Cardano.CoinSelection.Fee
( DustThreshold (..)
, Fee (..)
, FeeAdjustmentError (..)
, FeeBalancingPolicy (..)
, FeeEstimator (..)
, FeeOptions (..)
, adjustForFee
, coalesceDust
, distributeFee
, reduceChangeOutputs
, splitCoin
)
import Cardano.Test.Utilities
( Address (..)
, Hash (..)
, ShowFmt (..)
, TxIn (..)
, unsafeCoin
, unsafeDustThreshold
, unsafeFee
)
import Control.Arrow
( left )
import Control.Monad
( replicateM )
import Control.Monad.IO.Class
( liftIO )
import Control.Monad.Trans.Except
( runExceptT )
import Crypto.Random
( SystemDRG, getSystemDRG )
import Crypto.Random.Types
( withDRG )
import Data.Either
( isRight )
import Data.Function
( (&) )
import Data.Functor.Identity
( Identity (runIdentity) )
import Data.List.NonEmpty
( NonEmpty (..) )
import Data.Monoid
( All (..) )
import Data.Ratio
( (%) )
import Fmt
( Buildable (..), nameF, tupleF )
import GHC.Generics
( Generic )
import Internal.Coin
( Coin, coinToIntegral )
import Internal.Rounding
( RoundingDirection (..), round )
import Test.Hspec
( Spec, SpecWith, before, describe, it, shouldBe, shouldSatisfy )
import Test.QuickCheck
( Arbitrary (..)
, Gen
, Positive (getPositive)
, Property
, arbitraryBoundedIntegral
, checkCoverage
, choose
, counterexample
, cover
, coverTable
, disjoin
, elements
, expectFailure
, generate
, genericShrink
, oneof
, property
, scale
, tabulate
, vectorOf
, withMaxSuccess
, (.&&.)
, (===)
, (==>)
)
import Test.QuickCheck.Monadic
( monadicIO )
import qualified Cardano.CoinSelection as CS
import qualified Data.ByteString as BS
import qualified Data.Foldable as F
import qualified Data.List as L
import qualified Data.List.NonEmpty as NE
import qualified Data.Map.Strict as Map
import qualified Internal.Coin as C
spec :: Spec
spec = do
describe "Fee calculation : unit tests" $ do
-- Change covers fee exactly, single change output
feeUnitTest (FeeFixture
{ fInps = [20]
, fOuts = [17]
, fChngs = [3]
, fUtxo = []
, fFee = 3
, fDust = 0
}) (Right $ FeeOutput
{ csInps = [20]
, csOuts = [17]
, csChngs = []
})
-- Total change covers fee, multiple change outputs
feeUnitTest (FeeFixture
{ fInps = [20,20]
, fOuts = [16,18]
, fChngs = [4,2]
, fUtxo = []
, fFee = 6
, fDust = 0
}) (Right $ FeeOutput
{ csInps = [20,20]
, csOuts = [16,18]
, csChngs = []
})
-- Fee split evenly across change outputs
feeUnitTest (FeeFixture
{ fInps = [20,20]
, fOuts = [18,18]
, fChngs = [2,2]
, fUtxo = []
, fFee = 2
, fDust = 0
}) (Right $ FeeOutput
{ csInps = [20,20]
, csOuts = [18,18]
, csChngs = [1,1]
})
-- Fee split evenly across change outputs
feeUnitTest (FeeFixture
{ fInps = [20,20]
, fOuts = [17,18]
, fChngs = [3,2]
, fUtxo = []
, fFee = 2
, fDust = 0
}) (Right $ FeeOutput
{ csInps = [20,20]
, csOuts = [17,18]
, csChngs = [2,1]
})
-- Fee divvied, dust removed (dust = 0)
feeUnitTest (FeeFixture
{ fInps = [20,20,20]
, fOuts = [14,18,19]
, fChngs = [6,2,1]
, fUtxo = []
, fFee = 3
, fDust = 0
}) (Right $ FeeOutput
{ csInps = [20,20,20]
, csOuts = [14,18,19]
, csChngs = [4,1,1]
})
-- Fee divvied, dust removed (dust = 1)
feeUnitTest (FeeFixture
{ fInps = [20,20,20]
, fOuts = [14,18,19]
, fChngs = [6,2,1]
, fUtxo = []
, fFee = 3
, fDust = 1
}) (Right $ FeeOutput
{ csInps = [20,20,20]
, csOuts = [14,18,19]
, csChngs = [6]
})
-- Cannot cover fee, no extra inputs
feeUnitTest (FeeFixture
{ fInps = [20]
, fOuts = [17]
, fChngs = [3]
, fUtxo = []
, fFee = 4
, fDust = 0
}) (Left $ CannotCoverFee $ unsafeFee @Int 1)
-- Cannot cover fee even with an extra (too small) inputs
feeUnitTest (FeeFixture
{ fInps = [10]
, fOuts = [7]
, fChngs = [3]
, fUtxo = [1]
, fFee = 5
, fDust = 0
}) (Left $ CannotCoverFee $ unsafeFee @Int 1)
-- Can select extra inputs to exactly cover fee, no change back
feeUnitTest (FeeFixture
{ fInps = [10]
, fOuts = [7]
, fChngs = [3]
, fUtxo = [1,1]
, fFee = 5
, fDust = 0
}) (Right $ FeeOutput
{ csInps = [10,1,1]
, csOuts = [7]
, csChngs = []
})
-- Can select extra inputs to cover for fee, and leave a change back
feeUnitTest (FeeFixture
{ fInps = [10]
, fOuts = [7]
, fChngs = [3]
, fUtxo = [3]
, fFee = 5
, fDust = 0
}) (Right $ FeeOutput
{ csInps = [10,3]
, csOuts = [7]
, csChngs = [1]
})
-- Multiple change output, can select extra inputs to cover fee, no
-- change
feeUnitTest (FeeFixture
{ fInps = [10,10]
, fOuts = [7,7]
, fChngs = [3,3]
, fUtxo = [2,2]
, fFee = 10
, fDust = 0
}) (Right $ FeeOutput
{ csInps = [10,10,2,2]
, csOuts = [7,7]
, csChngs = []
})
-- Multiple outputs, extra inputs selected, resulting change
feeUnitTest (FeeFixture
{ fInps = [10,10]
, fOuts = [7,7]
, fChngs = [3,3]
, fUtxo = [3,3]
, fFee = 10
, fDust = 0
}) (Right $ FeeOutput
{ csInps = [10,10,3,3]
, csOuts = [7,7]
, csChngs = [1,1]
})
-- Multiple change outputs, some bigger than actual Dust
feeUnitTest (FeeFixture
{ fInps = [20,20]
, fOuts = [16,18]
, fChngs = [4,2]
, fUtxo = []
, fFee = 6
, fDust = 2
}) (Right $ FeeOutput
{ csInps = [20,20]
, csOuts = [16,18]
, csChngs = []
})
-- Change created when there was no change before
feeUnitTest (FeeFixture
{ fInps = [1]
, fOuts = [1]
, fChngs = []
, fUtxo = [2]
, fFee = 1
, fDust = 0
}) (Right $ FeeOutput
{ csInps = [1,2]
, csOuts = [1]
, csChngs = [1]
})
feeUnitTest (FeeFixture
{ fInps = []
, fOuts = []
, fChngs = []
, fUtxo = [3]
, fFee = 3
, fDust = 0
}) (Right $ FeeOutput
{ csInps = [3]
, csOuts = []
, csChngs = []
})
feeUnitTest (FeeFixture
{ fInps = []
, fOuts = []
, fChngs = []
, fUtxo = [2,2]
, fFee = 3
, fDust = 0
}) (Right $ FeeOutput
{ csInps = [2,2]
, csOuts = []
, csChngs = [1]
})
feeUnitTest (FeeFixture
{ fInps = []
, fOuts = []
, fChngs = []
, fUtxo = [2,2]
, fFee = 3
, fDust = 1
}) (Right $ FeeOutput
{ csInps = [2,2]
, csOuts = []
, csChngs = [1]
})
describe "Fee Calculation: Generators" $ do
it "Arbitrary CoinSelection" $ property $ \(ShowFmt cs) ->
property $ isValidSelection @TxIn @Address cs
before getSystemDRG $ describe "Fee Adjustment properties" $ do
it "Fee adjustment is deterministic when there's no extra inputs"
(\_ -> property $ propDeterministic @TxIn @Address)
it "Adjusting for fee (/= 0) reduces the change outputs or increase \
\inputs"
(property . propReducedChanges @TxIn @Address)
describe "distributeFee" $ do
it "fee portions are all within unity of ideal unrounded portions"
(checkCoverage propDistributeFeeFair)
it "fee portions are allocated optimally"
(checkCoverage propDistributeFeeOptimal)
it "Σ fst (distributeFee fee outs) == fee"
(checkCoverage propDistributeFeeSame)
it "snd (distributeFee fee outs) == outs"
(checkCoverage propDistributeFeeOuts)
it "expectFailure: not (any null (fst <$> distributeFee fee outs))"
(expectFailure propDistributeFeeNoNullFee)
describe "coalesceDust" $ do
it "sum coins = sum (coalesceDust threshold coins)"
(checkCoverage propCoalesceDustPreservesSum)
it "all (/= Coin 0) (coalesceDust threshold coins)"
(checkCoverage propCoalesceDustLeavesNoZeroCoins)
it "leaves at most one dust coin"
(checkCoverage propCoalesceDustLeavesAtMostOneDustCoin)
it "length coins >= (coalesceDust threshold coins)"
(checkCoverage propCoalesceDustNeverLengthensList)
describe "reduceChangeOutputs" $ do
it "data coverage is adequate"
(checkCoverage propReduceChangeOutputsDataCoverage)
it "the fee balancing algorithm converges for any coin selection"
( withMaxSuccess 100_000
$ propReduceChangeOutputsConverge @TxIn @Address
)
describe "splitCoin" $ do
it "data coverage is adequate"
(checkCoverage propSplitCoinDataCoverage)
it "preserves the total sum"
(checkCoverage propSplitCoinPreservesSum)
it "results are all within unity of ideal unrounded results"
(checkCoverage propSplitCoinFair)
--------------------------------------------------------------------------------
-- Fee Adjustment - Properties
--------------------------------------------------------------------------------
-- Check whether a selection is valid
isValidSelection :: CoinSelection i o -> Bool
isValidSelection s =
sumInputs s >= sumOutputs s `C.add` sumChange s
-- | Data for running fee calculation properties
data FeeProp i o = FeeProp
{ selection :: CoinSelection i o
-- ^ inputs from wich largestFirst can be calculated
, availableUtxo :: CoinMap i
-- ^ additional UTxO from which fee calculation will pick needed coins
, feeDust :: (Integer, Integer)
-- ^ constant fee and dust threshold
} deriving Show
instance (Buildable i, Buildable o) =>
Buildable (FeeProp i o) where
build (FeeProp cc utxo opt) = mempty
<> nameF "selection" (build cc)
<> nameF "utxo" (build (coinMapToList utxo))
<> nameF "options" (tupleF opt)
propDeterministic
:: forall i o . (Ord i, Ord o, Show i, Show o)
=> ShowFmt (FeeProp i o)
-> Property
propDeterministic (ShowFmt (FeeProp coinSel _ (fee, dust))) =
monadicIO $ liftIO $ do
let feeOpt = feeOptions fee dust
let utxo = mempty
resultOne <- runExceptT $ adjustForFee feeOpt utxo coinSel
resultTwo <- runExceptT $ adjustForFee feeOpt utxo coinSel
resultOne `shouldBe` resultTwo
propReducedChanges
:: forall i o . (Ord i, Show i, Show o)
=> SystemDRG
-> ShowFmt (FeeProp i o)
-> Property
propReducedChanges drg (ShowFmt (FeeProp coinSel utxo (fee, dust))) = do
isRight coinSel' ==> let Right s = coinSel' in prop s
where
prop s = do
let chgs' = F.fold $ change s
let chgs = F.fold $ change coinSel
let inps' = CS.inputs s
let inps = CS.inputs coinSel
disjoin
[ chgs' `shouldSatisfy` (<= chgs)
, length inps' `shouldSatisfy` (>= length inps)
]
feeOpt = feeOptions fee dust
coinSel' = left show $ fst $ withDRG drg $ runExceptT $
adjustForFee feeOpt utxo coinSel
--------------------------------------------------------------------------------
-- distributeFee - Properties
--------------------------------------------------------------------------------
-- | Helper to re-apply the pre-conditions for distributeFee
propDistributeFee
:: ((Fee, NonEmpty Coin) -> Property)
-> (Fee, NonEmpty Coin)
-> Property
propDistributeFee prop (fee, outs) =
coverTable "properties"
[ ("fee > 0", 50)
, ("nOuts=1", 1)
, ("nOuts=2", 1)
, ("nOuts=2+", 10)
]
$ tabulate "properties"
[ if fee > Fee C.zero then "fee > 0" else "fee == 0"
, "nOuts=" <> case length outs of
n | n <= 2 -> show n
_ -> "2+"
]
$ prop (fee, outs)
-- | Verify that fees are distributed fairly across outputs, so that every
-- rounded fee portion is always within unity of the ideal unrounded fee
-- portion.
propDistributeFeeFair
:: (Fee, NonEmpty Coin)
-> Property
propDistributeFeeFair (fee, coins) = (.&&.)
(F.all (uncurry (<=)) (NE.zip fees feeUpperBounds))
(F.all (uncurry (>=)) (NE.zip fees feeLowerBounds))
where
fees = fst <$> distributeFee fee coins
feeUpperBounds = unsafeFee @Integer . ceiling . computeIdealFee <$> coins
feeLowerBounds = unsafeFee @Integer . floor . computeIdealFee <$> coins
computeIdealFee :: Coin -> Rational
computeIdealFee c
= coinToIntegral c
* coinToIntegral (unFee fee)
% coinToIntegral (F.fold coins)
-- | Verify that fees are distributed optimally across coins, such that the
-- absolute deviation from the ideal (unrounded) fee distribution is minimal.
propDistributeFeeOptimal
:: Fee
-> NonEmpty Coin
-> Property
propDistributeFeeOptimal fee coins = property $
computeDeviation (fst <$> distributeFee fee coins)
`shouldBe` minimumPossibleDeviation
where
-- Compute the deviation of a fee portion distribution from the ideal
-- unrounded fee portion distribution.
computeDeviation :: NonEmpty Fee -> Rational
computeDeviation feesRounded = F.sum $
NE.zipWith zipper
feesRounded
idealUnroundedDistribution
where
zipper :: Fee -> Rational -> Rational
zipper (Fee roundedFee) unroundedFee = abs $
fromIntegral (coinToIntegral @Integer roundedFee) - unroundedFee
-- The minimum deviation across all possible distributions for the given
-- fee and set of coins.
minimumPossibleDeviation :: Rational
minimumPossibleDeviation =
F.minimum $ computeDeviation <$> allPossibleDistributions
-- The set of all possible fee distributions for the given fee and coins.
allPossibleDistributions :: [NonEmpty Fee]
allPossibleDistributions = filter isValidDistribution $ NE.zipWith
(\f roundDir ->
unsafeFee @Integer $ fromIntegral @Integer $ round roundDir f)
idealUnroundedDistribution <$> allPossibleRoundings
where
-- Indicates whether the given distribution has the correct total fee.
isValidDistribution :: NonEmpty Fee -> Bool
isValidDistribution r = F.fold r == fee
-- All possible ways to round an unrounded fee distribution.
allPossibleRoundings :: [NonEmpty RoundingDirection]
allPossibleRoundings = traverse (const [RoundUp, RoundDown]) coins
-- The ideal unrounded fee distribution.
idealUnroundedDistribution :: NonEmpty Rational
idealUnroundedDistribution = computeIdealFee <$> coins
where
computeIdealFee :: Coin -> Rational
computeIdealFee c
= coinToIntegral c
* coinToIntegral (unFee fee)
% coinToIntegral (F.fold coins)
-- | Sum of the fees divvied over each output is the same as the initial total
-- fee.
propDistributeFeeSame
:: (Fee, NonEmpty Coin)
-> Property
propDistributeFeeSame = propDistributeFee $ \(fee, outs) ->
F.fold (fst <$> distributeFee fee outs) === fee
-- | distributeFee doesn't change any of the outputs
propDistributeFeeOuts
:: (Fee, NonEmpty Coin)
-> Property
propDistributeFeeOuts = propDistributeFee $ \(fee, outs) ->
(snd <$> distributeFee fee outs) === outs
-- | distributeFee never generates null fees for a given output.
--
-- This is NOT a property. It is here to illustrate that this can happen in
-- practice, and is known as a possible outcome for the distributeFee function
-- (it is fine for one of the output to be assigned no fee). The only reason
-- this would happen is because there would be less outputs than the fee amount
-- which is probably never going to happen in practice...
propDistributeFeeNoNullFee
:: (Fee, NonEmpty Coin)
-> Property
propDistributeFeeNoNullFee (fee, outs) =
not (null outs) ==> withMaxSuccess 100000 prop
where
prop = property $ Fee C.zero `F.notElem` (fst <$> distributeFee fee outs)
--------------------------------------------------------------------------------
-- coalesceDust - Properties
--------------------------------------------------------------------------------
data CoalesceDustData = CoalesceDustData
{ cddThreshold :: DustThreshold
, cddCoins :: NonEmpty Coin
} deriving (Eq, Generic, Show)
instance Arbitrary CoalesceDustData where
arbitrary = do
coinCount <- genCoinCount
coins <- (:|) <$> genCoin <*> replicateM coinCount genCoin
threshold <- DustThreshold <$> oneof
[ -- Two possibilities:
genCoin
-- ^ A completely fresh coin.
, elements (F.toList coins)
-- ^ A coin picked from the existing coin set.
]
pure $ CoalesceDustData threshold coins
where
genCoin = unsafeCoin @Int <$> oneof [pure 0, choose (1, 100)]
genCoinCount = choose (0, 10)
shrink = genericShrink
propCoalesceDustPreservesSum :: CoalesceDustData -> Property
propCoalesceDustPreservesSum (CoalesceDustData threshold coins) =
property $
let total = F.fold coins in
cover 8 (total == C.zero) "sum coins = 0" $
cover 8 (total /= C.zero) "sum coins ≠ 0" $
total == F.fold (coalesceDust threshold coins)
propCoalesceDustLeavesNoZeroCoins :: CoalesceDustData -> Property
propCoalesceDustLeavesNoZeroCoins (CoalesceDustData threshold coins) =
property $
cover 4 (F.all (== C.zero) coins) "∀ coin ∈ coins . coin = 0" $
cover 4 (F.elem (C.zero) coins) "∃ coin ∈ coins . coin = 0" $
cover 8 (F.notElem (C.zero) coins) "∀ coin ∈ coins . coin > 0" $
F.notElem C.zero $ coalesceDust threshold coins
propCoalesceDustLeavesAtMostOneDustCoin :: CoalesceDustData -> Property
propCoalesceDustLeavesAtMostOneDustCoin (CoalesceDustData threshold coins) =
property $
let result = coalesceDust threshold coins in
-- Check that we cover different kinds of extreme threshold conditions:
cover 2 (F.all (< threshold') coins) "∀ coin ∈ coins . coin < threshold" $
cover 2 (F.all (> threshold') coins) "∀ coin ∈ coins . coin > threshold" $
cover 2 (F.all (== threshold') coins) "∀ coin ∈ coins . coin = threshold" $
cover 2 (F.all (/= threshold') coins) "∀ coin ∈ coins . coin ≠ threshold" $
-- Check that we cover typical threshold conditions:
let haveMixture = getAll $ mconcat $ All <$>
[ F.any (< threshold') coins
, F.any (== threshold') coins
, F.any (> threshold') coins
] in
cover 8 haveMixture "have mixture of coin values in relation to threshold" $
-- Check that we cover different result lengths:
cover 8 (null result) "length result = 0" $
cover 8 (length result == 1) "length result = 1" $
cover 8 (length result >= 2) "length result ≥ 2" $
case result of
[ ] -> F.fold coins == C.zero
[x] -> F.fold coins == x
cxs -> all (> threshold') cxs
where
threshold' = unDustThreshold threshold
propCoalesceDustNeverLengthensList :: CoalesceDustData -> Property
propCoalesceDustNeverLengthensList (CoalesceDustData threshold coins) =
property $ length coins >= length (coalesceDust threshold coins)
--------------------------------------------------------------------------------
-- reduceChangeOutputs - Properties
--------------------------------------------------------------------------------
data ReduceChangeOutputsData = ReduceChangeOutputsData
{ rcodFee :: Fee
, rcodThreshold :: DustThreshold
, rcodCoins :: [Coin]
} deriving (Eq, Generic, Show)
instance Arbitrary ReduceChangeOutputsData where
arbitrary = do
coalesceDustData <- arbitrary
let threshold = cddThreshold coalesceDustData
let coins = F.toList $ cddCoins coalesceDustData
let coinSum = sum $ coinToIntegral <$> coins
fee <- unsafeFee <$> oneof
[ pure 0
, choose (1, safePred coinSum)
, pure coinSum
, choose (coinSum + 1, coinSum * 2)
]
pure $ ReduceChangeOutputsData fee threshold coins
where
safePred :: Integer -> Integer
safePred x
| x > 0 = x - 1
| otherwise = 0
shrink = genericShrink
propReduceChangeOutputsDataCoverage :: ReduceChangeOutputsData -> Property
propReduceChangeOutputsDataCoverage
(ReduceChangeOutputsData (Fee fee) _ coins) =
let coinSum = F.fold coins in
property
-- Test coverage of fee amount, relative to sum of coins:
$ cover 100 (fee >= C.zero)
"fee >= 0"
$ cover 8 (fee == C.zero)
"fee = 0"
$ cover 8 (length (filter (> C.zero) coins) == 1)
"one non-empty coin"
$ cover 8 (length (filter (> C.zero) coins) == 2)
"two non-empty coins"
$ cover 8 (length (filter (> C.zero) coins) >= 3)
"several non-empty coins"
$ cover 8 (any (> C.zero) coins && C.zero < fee && fee < coinSum)
"0 < fee < sum coins"
$ cover 8 (any (> C.zero) coins && fee == coinSum)
"fee = sum coins"
$ cover 8 (any (> C.zero) coins && fee > coinSum)
"fee > sum coins"
True
propReduceChangeOutputsConverge
:: (Show i, Show o)
=> CoinSelection i o
-> FeeOptions i o
-> Property
propReduceChangeOutputsConverge sel opts = do
let Right (sel', remainder) = reduceChangeOutputs opts sel
let fee = estimateFee (feeEstimator opts) sel'
let prop = case feeBalancingPolicy opts of
-- If fees are null and we require balanced selections, then the
-- selection must be exactly balanced.
RequireBalancedFee | remainder == Fee C.zero ->
(Fee <$> delta sel') == Just fee
-- Otherwise, either the change outputs are null, or the delta is
-- positive because of the dangling output and we'll have to pay for
-- it.
RequireBalancedFee ->
null (change sel') || (delta sel' >= Just C.zero)
-- If fees are null and we do not require balanced selections, then
-- the selection must leave __at least__ the fee amount.
RequireMinimalFee | remainder == Fee C.zero ->
(Fee <$> delta sel') >= Just fee
-- Otherwise, the change outputs are necessarily null.
RequireMinimalFee ->
null (change sel')
property prop
& counterexample (unlines
[ "new selection: " <> show sel'
, "delta (before): " <> show (delta sel)
, "delta (after): " <> show (delta sel')
, "remainder: " <> show remainder
])
where
delta s = sumInputs s `C.sub` (sumOutputs s `C.add` sumChange s)
--------------------------------------------------------------------------------
-- splitCoin - Properties
--------------------------------------------------------------------------------
data SplitCoinData = SplitCoinData
{ scdCoinToSplit :: Coin
, scdCoinsToIncrease :: [Coin]
} deriving (Eq, Generic, Show)
instance Arbitrary SplitCoinData where
arbitrary = do
coinToSplit <- genCoin
n <- oneof
[ pure 0
, pure 1
, choose (2, 10)
]
coinsToIncrease <- replicateM n genCoin
pure $ SplitCoinData coinToSplit coinsToIncrease
where
genCoin :: Gen Coin
genCoin = oneof
[ pure $ unsafeCoin @Integer 0
, pure $ unsafeCoin @Integer 1
, unsafeCoin @Int . (+ 1) . getPositive <$> arbitrary
]
shrink = genericShrink
propSplitCoinDataCoverage :: SplitCoinData -> Property
propSplitCoinDataCoverage (SplitCoinData coinToSplit coinsToIncrease) =
property
$ cover 8 (null coinsToIncrease)
"list of coins is empty"
$ cover 8 (length coinsToIncrease == 1)
"list of coins is singleton"
$ cover 8 (length coinsToIncrease > 1)
"list of coins has multiple entries"
$ cover 8 (C.zero `elem` coinsToIncrease)
"list of coins has at least one zero coin"
$ cover 8 (any (> C.zero) coinsToIncrease)
"list of coins has at least one non-zero coin"
$ cover 8 (coinToSplit == C.zero)
"coin to split is zero"
$ cover 8 (coinToSplit > C.zero)
"coin to split is non-zero"
$ cover 8 (length coinsToIncrease > coinToIntegral coinToSplit)
"coin to split is smaller than number of coins to increase"
True
propSplitCoinPreservesSum :: SplitCoinData -> Property
propSplitCoinPreservesSum (SplitCoinData coinToSplit coinsToIncrease) =
property $ totalBefore `shouldBe` totalAfter
where
totalAfter = F.fold (splitCoin coinToSplit coinsToIncrease)
totalBefore = coinToSplit `C.add` F.fold coinsToIncrease
propSplitCoinFair :: (Coin, NonEmpty Coin) -> Property
propSplitCoinFair (coinToSplit, coinsToIncrease) = (.&&.)
(F.all (uncurry (<=)) (NE.zip results upperBounds))
(F.all (uncurry (>=)) (NE.zip results lowerBounds))
where
results = NE.fromList $ splitCoin coinToSplit $ NE.toList coinsToIncrease
upperBounds = unsafeCoin @Integer . ceiling
. computeIdealResult <$> coinsToIncrease
lowerBounds = unsafeCoin @Integer . floor
. computeIdealResult <$> coinsToIncrease
computeIdealResult :: Coin -> Rational
computeIdealResult c
= fromIntegral (coinToIntegral @Integer c)
+ coinToIntegral @Integer coinToSplit
% fromIntegral (length coinsToIncrease)
--------------------------------------------------------------------------------
-- Fee Adjustment - Unit Tests
--------------------------------------------------------------------------------
feeOptions
:: Integer
-> Integer
-> FeeOptions i o
feeOptions fee dust = FeeOptions
{ feeEstimator = FeeEstimator $
\_ -> unsafeFee fee
, dustThreshold =
unsafeDustThreshold dust
, feeBalancingPolicy =
RequireBalancedFee
}
feeUnitTest
:: FeeFixture
-> Either (FeeAdjustmentError TxIn Address) FeeOutput
-> SpecWith ()
feeUnitTest (FeeFixture inpsF outsF chngsF utxoF feeF dustF) expected =
it title $ do
(utxo, sel) <- setup @TxIn @Address
result <- runExceptT $ do
(CoinSelection inps outs chngs) <-
adjustForFee (feeOptions feeF dustF) utxo sel
return $ FeeOutput
{ csInps = coinToIntegral . entryValue <$> coinMapToList inps
, csOuts = coinToIntegral . entryValue <$> coinMapToList outs
, csChngs = coinToIntegral <$> chngs
}
fmap sortFeeOutput result `shouldBe` fmap sortFeeOutput expected
where
setup
:: forall i o . (Arbitrary i, Arbitrary o, Ord i, Ord o)
=> IO (CoinMap i, CoinSelection i o)
setup = do
utxo <- generate (genInputs $ unsafeCoin <$> utxoF)
inps <- (fmap (uncurry CoinMapEntry) . Map.toList . unCoinMap) <$>
generate (genInputs $ unsafeCoin <$> inpsF)
outs <- generate (genOutputs $ unsafeCoin <$> outsF)
let chngs = map unsafeCoin chngsF
pure (utxo, CoinSelection (coinMapFromList inps) outs chngs)
title :: String
title = mempty
<> "CoinSelection (inps=" <> show inpsF
<> "outs=" <> show outsF
<> "chngs=" <> show chngsF
<> "), UTxO=" <> show utxoF
<> "), fee=" <> show feeF
<> " --> " <> show expected
-- | A fixture for testing the fee calculation
data FeeFixture = FeeFixture
{ fInps :: [Integer]
-- ^ Value (in Lovelace) & number of coins in inputs
, fOuts :: [Integer]
-- ^ Value (in Lovelace) & number of requested outputs
, fChngs :: [Integer]
-- ^ Value (in Lovelace) & number of changes
, fUtxo :: [Integer]
-- ^ Value (in Lovelace) & number of available coins in the UTxO
, fFee :: Integer
-- ^ Value (in Lovelace) of rigid fee
, fDust :: Integer
-- ^ Value (in Lovelace) of dust
} deriving Show
-- | A fee calculation output
data FeeOutput = FeeOutput
{ csInps :: [Integer]
-- ^ Value (in Lovelace) & number of available coins in the UTxO
, csOuts :: [Integer]
-- ^ Value (in Lovelace) & number of requested outputs
, csChngs :: [Integer]
-- ^ Value (in Lovelace) & number of changes
} deriving (Show, Eq)
sortFeeOutput :: FeeOutput -> FeeOutput
sortFeeOutput (FeeOutput is os cs) =
FeeOutput (L.sort is) (L.sort os) (L.sort cs)
--------------------------------------------------------------------------------
-- Arbitrary Instances
--------------------------------------------------------------------------------
deriving newtype instance Arbitrary a => Arbitrary (ShowFmt a)
genInputs :: (Arbitrary i, Ord i) => [Coin] -> Gen (CoinMap i)
genInputs coins = do
let n = length coins
inps <- vectorOf n arbitrary
return $ CoinMap $ Map.fromList $ zip inps coins
genOutputs :: (Arbitrary o, Ord o) => [Coin] -> Gen (CoinMap o)
genOutputs coins = do
let n = length coins
outs <- vectorOf n arbitrary
return $ coinMapFromList $ zipWith CoinMapEntry outs coins
genSelection
:: (Arbitrary i, Ord i, Ord o)
=> CoinMap o
-> Gen (CoinSelection i o)
genSelection outs = do
utxo <- vectorOf (length outs * 3) arbitrary >>= genInputs
let selectionLimit = CS.CoinSelectionLimit $ const 100
let params = CS.CoinSelectionParameters utxo outs selectionLimit
case runIdentity $ runExceptT $ selectCoins largestFirst params of
Left _ -> genSelection outs
Right (CoinSelectionResult s _) -> return s
instance Arbitrary TxIn where
shrink _ = []
arbitrary = TxIn
<$> arbitrary
<*> scale (`mod` 3) arbitrary -- No need for a high indexes
instance Arbitrary Coin where
arbitrary = unsafeCoin @Int <$> choose (1, 100_000)
shrink = fmap unsafeCoin . filter (> 0) . shrink . coinToIntegral @Int
instance Arbitrary DustThreshold where
arbitrary = unsafeDustThreshold @Int <$> choose (0, 100)
shrink = fmap DustThreshold . shrink . unDustThreshold
instance Arbitrary Fee where
arbitrary = unsafeFee @Int <$> choose (1, 100_000)
shrink = fmap Fee . shrink . unFee
instance (Arbitrary i, Arbitrary o, Ord i, Ord o) =>
Arbitrary (FeeProp i o)
where
shrink (FeeProp cs utxo opts) =
case Map.toList $ unCoinMap utxo of
[] ->
map (\cs' -> FeeProp cs' utxo opts) (shrink cs)
us ->
concatMap (\cs' ->
[ FeeProp cs' mempty opts
, FeeProp cs' (CoinMap $ Map.fromList (drop 1 us)) opts
]
) (shrink cs)
arbitrary = do
cs <- arbitrary
utxo <- choose (0, 50)
>>= \n -> vectorOf n arbitrary
>>= genInputs
fee <- choose (100000, 500000)
dust <- choose (0, 10000)
return $ FeeProp cs utxo (fee, dust)
instance Arbitrary (Hash "Tx") where
shrink _ = []
arbitrary = do
bytes <- BS.pack <$> vectorOf 8 arbitraryBoundedIntegral
pure $ Hash bytes
instance Arbitrary Address where
shrink _ = []
arbitrary = do
bytes <- BS.pack <$> vectorOf 8 arbitraryBoundedIntegral
pure $ Address bytes
instance (Arbitrary i, Arbitrary o, Ord i, Ord o) =>
Arbitrary (CoinSelection i o)
where
shrink sel = case unCoinSelection sel of
([_], [_], []) ->
[]
(inps, outs, chgs) ->
let
inps' = if length inps > 1 then drop 1 inps else inps
outs' = if length outs > 1 then drop 1 outs else outs
chgs' = if not (null chgs) then drop 1 chgs else chgs
in
filter (\s -> s /= sel && isValidSelection s) $
mkCoinSelection <$>
[ (inps', outs', chgs')
, (inps', outs , chgs )
, (inps , outs', chgs )
, (inps , outs , chgs')
]
where
unCoinSelection s =
(coinMapToList $ inputs s, coinMapToList $ outputs s, change s)
mkCoinSelection (is, os, cs) =
CoinSelection (coinMapFromList is) (coinMapFromList os) cs
arbitrary = do
outs <- choose (1, 10)
>>= \n -> vectorOf n arbitrary
>>= genOutputs
genSelection outs
data FeeParameters i o = FeeParameters
{ feePerTransaction
:: Fee
-- ^ Base fee for a transaction.
, feePerTransactionEntry
:: Fee
-- ^ Incremental fee for each input, output, and change output.
} deriving (Eq, Generic, Show)
instance Arbitrary (FeeParameters i o) where
arbitrary = do
feePerTransaction <- unsafeFee @Int <$> choose (0, 10)
feePerTransactionEntry <- unsafeFee @Int <$> choose (0, 10)
pure $ FeeParameters {feePerTransaction, feePerTransactionEntry}
shrink = genericShrink
-- An estimator that solely depends on the number of inputs and outputs.
stableEstimator :: FeeParameters i o -> FeeEstimator i o
stableEstimator
FeeParameters {feePerTransaction, feePerTransactionEntry} =
FeeEstimator $ \s -> Fee $ maybe C.zero
(C.add (unFee feePerTransaction))
(C.mul
(unFee feePerTransactionEntry)
(length (inputs s) + length (outputs s)))
-- An estimator which depends on the size of inputs and outputs, where the size
-- is a function of their number and their value. The bigger the coins, the
-- bigger the fee.
valueDependentEstimator :: FeeParameters i o -> FeeEstimator i o
valueDependentEstimator
FeeParameters {feePerTransaction, feePerTransactionEntry} =
FeeEstimator $ \s -> Fee $ maybe C.zero
(C.add (unFee feePerTransaction))
(C.mul
(unFee feePerTransactionEntry)
(length (inputs s) + length (outputs s) + size (change s)))
where
size :: [Coin] -> Int
size = sum . fmap sizeOfOne
where
sizeOfOne :: Coin -> Int
sizeOfOne coin
| coin < C.coinFromNatural 1_000 = 1
| coin < C.coinFromNatural 10_000 = 2
| coin < C.coinFromNatural 100_000 = 3
| otherwise = 4
instance Arbitrary (FeeOptions i o) where
arbitrary = do
dustThreshold <- unsafeDustThreshold @Int <$> choose (0, 10)
feeEstimator <- oneof
[ stableEstimator <$> arbitrary
, valueDependentEstimator <$> arbitrary
]
feeBalancingPolicy <- elements [RequireBalancedFee, RequireMinimalFee]
return $ FeeOptions {dustThreshold, feeEstimator, feeBalancingPolicy}
instance Arbitrary a => Arbitrary (NonEmpty a) where
arbitrary = do
tailLength <- choose (0, 10)
(:|) <$> arbitrary <*> replicateM tailLength arbitrary
shrink = genericShrink
instance Show (FeeOptions i o) where
show (FeeOptions _ dust policy) = show (dust, policy)