packages feed

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
--------------------------------------------------------------------------------