cardano-coin-selection-1.0.1: src/test/Cardano/CoinSelection/Algorithm/MigrationSpec.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Cardano.CoinSelection.Algorithm.MigrationSpec
( spec
) where
import Prelude
import Cardano.CoinSelection
( CoinMap (..)
, CoinMapEntry (..)
, CoinSelection (..)
, coinMapToList
, coinMapValue
, sumChange
, sumInputs
)
import Cardano.CoinSelection.Algorithm.Migration
( BatchSize (..), idealBatchSize, selectCoins )
import Cardano.CoinSelection.Fee
( DustThreshold (..)
, Fee (..)
, FeeBalancingPolicy (..)
, FeeEstimator (..)
, FeeOptions (..)
)
import Cardano.CoinSelection.FeeSpec
( FeeParameters, stableEstimator )
import Cardano.CoinSelectionSpec
()
import Cardano.Test.Utilities
( Address
, Hash (..)
, TxIn (..)
, unsafeCoin
, unsafeDustThreshold
, unsafeFee
)
import Data.ByteString
( ByteString )
import Data.Function
( (&) )
import Internal.Coin
( Coin, coinToIntegral )
import Numeric.Natural
( Natural )
import Test.Hspec
( Spec, SpecWith, describe, it, shouldSatisfy )
import Test.QuickCheck
( Arbitrary (..)
, Gen
, Property
, arbitrarySizedIntegral
, choose
, conjoin
, counterexample
, frequency
, label
, property
, scale
, vectorOf
, withMaxSuccess
, (===)
)
import Test.QuickCheck.Monadic
( monadicIO, monitor, pick )
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as B8
import qualified Data.Map as Map
import qualified Data.Set as Set
import qualified Internal.Coin as C
spec :: Spec
spec = do
describe "idealBatchSize" $ do
it "Eventually converge for decreasing functions" $ do
property $ \coinselOpts -> do
let batchSize = idealBatchSize coinselOpts
label (show batchSize) True
describe "accuracy of selectCoins" $ do
let testAccuracy :: Double -> SpecWith ()
testAccuracy r = it title $ withMaxSuccess 1000 $ monadicIO $ do
let dust = unsafeCoin @Int 100
utxo <- pick (genUTxO r dust)
batchSize <- pick genBatchSize
feeOpts <- pick (genFeeOptions dust)
let selections = selectCoins feeOpts batchSize utxo
monitor $ label $ accuracy dust
(coinToIntegral $ coinMapValue utxo)
(sum $ coinToIntegral . sumInputs <$> selections)
where
title :: String
title = "dust=" <> show (round (100 * r) :: Int) <> "%"
accuracy :: Coin -> Natural -> Natural -> String
accuracy dust sup real
| a >= 1.0 =
"PERFECT (== 100%)"
| a > 0.99 || (sup - real) < coinToIntegral dust =
"OKAY (> 99%)"
| otherwise =
"MEDIOCRE (<= 99%)"
where
a = double real / double sup
double = fromRational @Double . fromIntegral
mapM_ testAccuracy [ 0.01 , 0.05 , 0.10 , 0.25 , 0.50 ]
describe "selectCoins properties" $ do
it "No coin selection has outputs" $
property $ withMaxSuccess 10_000 $ prop_onlyChangeOutputs
@(Wrapped TxIn) @Address
it "Every coin in the selection change > dust threshold" $
property $ withMaxSuccess 10_000 $ prop_allAboveThreshold
@(Wrapped TxIn) @Address
it "Total input UTxO value >= sum of selection change coins" $
property $ withMaxSuccess 10_000 $ prop_inputsGreaterThanOutputs
@(Wrapped TxIn) @Address
it "Every selection input is unique" $
property $ withMaxSuccess 10_000 $ prop_inputsAreUnique
@(Wrapped TxIn) @Address
it "Every selection input is a member of the UTxO" $
property $ withMaxSuccess 10_000 $ prop_inputsStillInUTxO
@(Wrapped TxIn) @Address
it "Every coin selection is well-balanced" $
property $ withMaxSuccess 10_000 $ prop_wellBalanced
@(Wrapped TxIn) @Address
describe "selectCoins regressions" $ do
it "regression #1" $ do
let feeOpts = FeeOptions
{ dustThreshold = unsafeDustThreshold @Int 9
, feeEstimator = FeeEstimator $ \s -> unsafeFee @Int
$ fromIntegral
$ 5 * (length (inputs s) + length (outputs s))
, feeBalancingPolicy = RequireBalancedFee
}
let batchSize = BatchSize 1
let utxo = CoinMap $ Map.fromList
[ ( TxIn
{ txinId = Hash "|\243^\SUBg\242\231\&1\213\203"
, txinIx = 2
}
, unsafeCoin @Int 2
)
]
property $ prop_inputsGreaterThanOutputs
@TxIn @Address feeOpts batchSize utxo
--------------------------------------------------------------------------------
-- Properties
--------------------------------------------------------------------------------
-- | No coin selection has outputs
prop_onlyChangeOutputs
:: forall i o . (Ord i, Ord o, Show o)
=> FeeOptions i o
-> BatchSize
-> CoinMap i
-> Property
prop_onlyChangeOutputs feeOpts batchSize utxo = do
let allOutputs =
coinMapToList . outputs =<< selectCoins feeOpts batchSize utxo
property (allOutputs `shouldSatisfy` null)
-- | Every coin in the selection change > dust threshold
prop_allAboveThreshold
:: forall i o . (Ord i, Ord o)
=> FeeOptions i o
-> BatchSize
-> CoinMap i
-> Property
prop_allAboveThreshold feeOpts batchSize utxo = do
let allChange = change
=<< selectCoins feeOpts batchSize utxo
let undersizedCoins =
filter (<= threshold) allChange
property (undersizedCoins `shouldSatisfy` null)
where
threshold = unDustThreshold $ dustThreshold feeOpts
-- | Total input UTxO value >= sum of selection change coins
prop_inputsGreaterThanOutputs
:: forall i o . (Ord i, Ord o, Show i, Show o)
=> FeeOptions i o
-> BatchSize
-> CoinMap i
-> Property
prop_inputsGreaterThanOutputs feeOpts batchSize utxo = do
let selections = selectCoins feeOpts batchSize utxo
let totalChange = mconcat (sumChange <$> selections)
let balanceUTxO = coinMapValue utxo
property (balanceUTxO >= totalChange)
& counterexample ("Total change balance: " <> show totalChange)
& counterexample ("Total UTxO balance: " <> show balanceUTxO)
& counterexample ("Selections: " <> show selections)
-- | Every selected input is unique, i.e. selected only once
prop_inputsAreUnique
:: forall i o . (Ord i, Ord o)
=> FeeOptions i o
-> BatchSize
-> CoinMap i
-> Property
prop_inputsAreUnique feeOpts batchSize utxo = do
let selectionInputList =
coinMapToList . inputs =<< selectCoins feeOpts batchSize utxo
let selectionInputSet =
Set.fromList selectionInputList
Set.size selectionInputSet === length selectionInputSet
-- | Every selection input is still a member of the UTxO" $
prop_inputsStillInUTxO
:: forall i o . (Ord i, Ord o)
=> FeeOptions i o
-> BatchSize
-> CoinMap i
-> Property
prop_inputsStillInUTxO feeOpts batchSize utxo = do
let selectionInputSet = Set.fromList $
coinMapToList . inputs =<< selectCoins feeOpts batchSize utxo
let utxoSet = Set.fromList $
fmap (uncurry CoinMapEntry) $ Map.toList $ unCoinMap utxo
property (selectionInputSet `Set.isSubsetOf` utxoSet)
-- | Every coin selection is well-balanced (i.e. actual fees are exactly the
-- expected fees)
prop_wellBalanced
:: forall i o . (Ord i, Ord o, Show i, Show o)
=> FeeParameters i o
-> BatchSize
-> CoinMap i
-> Property
prop_wellBalanced feeParams batchSize utxo = do
let feeOpts = FeeOptions
{ dustThreshold = DustThreshold mempty
, feeEstimator = stableEstimator feeParams
, feeBalancingPolicy = RequireBalancedFee
}
let selections = selectCoins feeOpts batchSize utxo
conjoin
[ counterexample example (actualFee === expectedFee)
| s <- selections
, let actualFee
= coinToIntegral (sumInputs s)
- coinToIntegral (sumChange s)
, let expectedFee
= coinToIntegral @Integer
$ unFee $ estimateFee (feeEstimator feeOpts) s
, let example = unlines
[ "Coin Selection: " <> show s
, "Actual fee: " <> show actualFee
, "Expected fee: " <> show expectedFee
]
]
--------------------------------------------------------------------------------
-- Arbitrary Instances
--------------------------------------------------------------------------------
-- A wrapper to avoid overlapping instances imported from other modules.
newtype Wrapped a = Wrapped { unwrap :: a }
deriving (Eq, Ord, Show)
-- TODO: Move similar Arbitrary instances to a shared module for better reuse.
instance Arbitrary (Wrapped TxIn) where
arbitrary = fmap Wrapped . TxIn
<$> fmap unwrap arbitrary
<*> scale (`mod` 3) arbitrary
-- TODO: Move similar Arbitrary instances to a shared module for better reuse.
instance Arbitrary (Wrapped (Hash "Tx")) where
arbitrary = Wrapped . Hash <$> (BS.pack <$> vectorOf 32 arbitrary)
instance Arbitrary BatchSize where
arbitrary = BatchSize <$> arbitrarySizedIntegral
shrink (BatchSize s) = BatchSize <$> shrink s
--------------------------------------------------------------------------------
-- Generators
--------------------------------------------------------------------------------
genBatchSize :: Gen BatchSize
genBatchSize = BatchSize <$> choose (50, 150)
genFeeOptions :: Coin -> Gen (FeeOptions TxIn Address)
genFeeOptions dust = do
pure $ FeeOptions
{ feeEstimator = FeeEstimator $ \s ->
let x = fromIntegral @_ @Integer
(length (inputs s) + length (outputs s))
in unsafeFee $
(C.coinToIntegral dust `div` 100) * x + C.coinToIntegral dust
, dustThreshold = DustThreshold dust
, feeBalancingPolicy = RequireBalancedFee
}
-- | Generate a given UTxO with a particular percentage of dust
genUTxO :: Double -> Coin -> Gen (CoinMap TxIn)
genUTxO r dust = do
n <- choose (10, 1000)
inps <- genTxIn n
coins <- vectorOf n genCoin
pure $ CoinMap $ Map.fromList $ zip inps coins
where
genTxIn :: Int -> Gen [TxIn]
genTxIn n = do
ids <- vectorOf n (Hash <$> genBytes 8)
ixs <- vectorOf n arbitrary
pure $ zipWith TxIn ids ixs
genBytes :: Int -> Gen ByteString
genBytes n = B8.pack <$> vectorOf n arbitrary
genCoin :: Gen Coin
genCoin = unsafeCoin @Int <$> frequency
[ (round (100*r), choose (1, integralDust))
, (round (100*(1-r)), choose (integralDust, 1000 * integralDust))
]
where
integralDust = C.coinToIntegral dust
--------------------------------------------------------------------------------
-- Utility Functions
--------------------------------------------------------------------------------