diff --git a/lib/core/cardano-wallet-core.cabal b/lib/core/cardano-wallet-core.cabal index 9cfdffd3cfa..3efba183adf 100644 --- a/lib/core/cardano-wallet-core.cabal +++ b/lib/core/cardano-wallet-core.cabal @@ -397,6 +397,7 @@ test-suite unit Cardano.Wallet.Primitive.Slotting.Legacy Cardano.Wallet.Primitive.SlottingSpec Cardano.Wallet.Primitive.SyncProgressSpec + Cardano.Wallet.Primitive.Types.AddressSpec Cardano.Wallet.Primitive.Types.CoinSpec Cardano.Wallet.Primitive.Types.HashSpec Cardano.Wallet.Primitive.Types.TokenBundleSpec @@ -404,6 +405,7 @@ test-suite unit Cardano.Wallet.Primitive.Types.TokenMapSpec.TypeErrorSpec Cardano.Wallet.Primitive.Types.TokenPolicySpec Cardano.Wallet.Primitive.Types.TokenQuantitySpec + Cardano.Wallet.Primitive.Types.UTxOSpec Cardano.Wallet.Primitive.Types.UTxOIndexSpec Cardano.Wallet.Primitive.Types.UTxOIndex.TypeErrorSpec Cardano.Wallet.Primitive.TypesSpec diff --git a/lib/core/src/Cardano/Wallet/Primitive/Model.hs b/lib/core/src/Cardano/Wallet/Primitive/Model.hs index 9d07dc7c763..0b6d9cea2fb 100644 --- a/lib/core/src/Cardano/Wallet/Primitive/Model.hs +++ b/lib/core/src/Cardano/Wallet/Primitive/Model.hs @@ -39,6 +39,9 @@ module Cardano.Wallet.Primitive.Model , applyBlock , applyBlocks , unsafeInitWallet + , applyTxToUTxO + , utxoFromTx + , spendTx -- * Accessors , currentTip @@ -74,21 +77,20 @@ import Cardano.Wallet.Primitive.Types.Tx , Tx (..) , TxIn (..) , TxMeta (..) - , TxOut (..) , TxStatus (..) , inputs , txOutCoin ) import Cardano.Wallet.Primitive.Types.UTxO - ( Dom (..), UTxO (..), balance, excluding, restrictedBy ) + ( Dom (..), UTxO (..), balance, excluding, filterByAddressM ) import Control.DeepSeq ( NFData (..), deepseq ) import Control.Monad - ( foldM, forM ) + ( foldM ) import Control.Monad.Extra ( mapMaybeM ) import Control.Monad.Trans.State.Strict - ( State, evalState, runState, state ) + ( State, StateT, evalState, runState, state ) import Data.Functor ( (<&>) ) import Data.Generics.Internal.VL.Lens @@ -98,7 +100,7 @@ import Data.Generics.Labels import Data.List.NonEmpty ( NonEmpty (..) ) import Data.Maybe - ( catMaybes, isJust ) + ( isJust ) import Data.Set ( Set ) import Fmt @@ -340,6 +342,56 @@ totalUTxO pending (Wallet u _ s) = entriesToExcludeForTx :: Tx -> Set TxIn entriesToExcludeForTx tx = Set.fromList $ fst <$> tx ^. #resolvedInputs +-- | Applies a transaction to a UTxO, moving it from one state from another. +-- When applying a transaction to a UTxO: +-- 1. We need to remove any unspents that have been spent in the transaction. +-- 2. Add any unspents that we've received via the transaction. +-- +-- We don't consider "ownership" here (is this address ours?), only "do we +-- know about this address" (i.e. is it present in our UTxO?). +-- +-- balance (applyTxToUTxO tx u) = balance u +-- + balance (utxoFromTx tx) +-- - balance (u `restrictedBy` inputs tx) +-- unUTxO (applyTxToUTxO tx u) = unUTxO u +-- `Map.union` unUTxO (utxoFromTx tx) +-- `Map.difference` unUTxO (u `restrictedBy` inputs tx) +-- applyTxToUTxO tx u = spend tx u <> utxoFromTx tx +-- applyTxToUTxO tx u = spend tx (u <> utxoFromTx tx) +applyTxToUTxO + :: Tx + -> UTxO + -> UTxO +applyTxToUTxO tx !u = spendTx tx u <> utxoFromTx tx + +-- | Remove unspents that have been consumed by the given transaction. +-- +-- spendTx tx u `isSubsetOf` u +-- balance (spendTx tx u) <= balance u +-- balance (spendTx tx u) = balance u - balance (u `restrictedBy` inputs tx) +-- spendTx tx u = u `excluding` inputs tx +-- spendTx tx (filterByAddress f u) = filterByAddress f (spendTx tx u) +-- spendTx tx (u <> utxoFromTx tx) = spendTx tx u <> utxoFromTx tx +spendTx :: Tx -> UTxO -> UTxO +spendTx tx !u = u `excluding` Set.fromList (inputs tx) + +-- | Construct a UTxO corresponding to a given transaction. It is important for +-- the transaction outputs to be ordered correctly, since they become available +-- inputs for the subsequent blocks. +-- +-- balance (utxoFromTx tx) = foldMap tokens (outputs tx) +-- utxoFromTx tx `excluding` Set.fromList (inputs tx) = utxoFrom tx +utxoFromTx :: Tx -> UTxO +utxoFromTx Tx {txId, outputs} = + UTxO $ Map.fromList $ zip (TxIn txId <$> [0..]) outputs + +isOurAddress + :: forall s m + . (Monad m, IsOurs s Address) + => Address + -> StateT s m Bool +isOurAddress = fmap isJust . state . isOurs + {------------------------------------------------------------------------------- Internals -------------------------------------------------------------------------------} @@ -406,16 +458,48 @@ prefilterBlock b u0 = runState $ do => ([(Tx, TxMeta)], UTxO) -> Tx -> State s ([(Tx, TxMeta)], UTxO) - applyTx (!txs, !u) tx = do - ourU <- state $ utxoOurs tx - let ourIns = Set.fromList (inputs tx) `Set.intersection` dom (u <> ourU) - let u' = (u <> ourU) `excluding` ourIns + applyTx (!txs, !prevUTxO) tx = do + -- The next UTxO state (apply a state transition) (e.g. remove + -- transaction outputs we've spent). + ourNextUTxO <- + (spendTx tx prevUTxO <>) + <$> filterByAddressM isOurAddress (utxoFromTx tx) + ourWithdrawals <- Coin . sum . fmap (unCoin . snd) <$> mapMaybeM ourWithdrawal (Map.toList $ withdrawals tx) - let received = balance ourU - let spent = balance (u `restrictedBy` ourIns) `TB.add` TB.fromCoin ourWithdrawals - let hasKnownInput = ourIns /= mempty - let hasKnownOutput = ourU /= mempty + + let received = balance (ourNextUTxO `excluding` dom prevUTxO) + let spent = + balance (prevUTxO `excluding` dom ourNextUTxO) + `TB.add` TB.fromCoin ourWithdrawals + + (ownedAndKnownTxIns, ownedAndKnownTxOuts) <- do + -- A new transaction expands the set of transaction inputs/outputs + -- we know about, but not all those transaction inputs/outputs + -- belong to us, so we filter any new inputs/outputs, presuming that + -- the previous UTxO has already been filtered: + ownedAndKnown <- + (prevUTxO <>) <$> filterByAddressM isOurAddress (utxoFromTx tx) + -- Also, the new transaction may spend some transaction + -- inputs/outputs. But we don't want to apply that logic yet. If we + -- do, any spent transaction input/output will be removed from our + -- knowledge base. + -- Therefore, because this is not technically an "Unspent TxO" set, + -- let's just return the TxIns and TxOuts, as the type "UTxO" will + -- create expectations which we explicitly aren't fulfilling: + let m = unUTxO ownedAndKnown + pure (Map.keys m, Map.elems m) + + -- A transaction has a known input if one of the transaction inputs + -- matches a transaction input we know about. + let hasKnownInput = not $ Set.disjoint + (Set.fromList $ inputs tx) + (Set.fromList ownedAndKnownTxIns) + -- A transaction has a known output if one of the transaction outputs + -- matches a transaction output we know about. + let hasKnownOutput = not $ Set.disjoint + (Set.fromList $ outputs tx) + (Set.fromList ownedAndKnownTxOuts) let hasKnownWithdrawal = ourWithdrawals /= mempty -- NOTE 1: The only case where fees can be 'Nothing' is when dealing with @@ -443,7 +527,7 @@ prefilterBlock b u0 = runState $ do return $ if hasKnownOutput && not hasKnownInput then let dir = Incoming in ( (tx { fee = actualFee dir }, mkTxMeta (TB.getCoin received) dir) : txs - , u' + , ourNextUTxO ) else if hasKnownInput || hasKnownWithdrawal then let @@ -453,10 +537,10 @@ prefilterBlock b u0 = runState $ do amount = distance adaSpent adaReceived in ( (tx { fee = actualFee dir }, mkTxMeta amount dir) : txs - , u' + , ourNextUTxO ) else - (txs, u) + (txs, prevUTxO) -- | Get the change UTxO -- @@ -471,21 +555,5 @@ changeUTxO -> s -> UTxO changeUTxO pending = evalState $ - mconcat <$> mapM (state . utxoOurs) (Set.toList pending) - --- | Construct our _next_ UTxO (possible empty) from a transaction by selecting --- outputs that are ours. It is important for the transaction outputs to be --- ordered correctly, since they become available inputs for the subsequent --- blocks. -utxoOurs - :: IsOurs s Address - => Tx - -> s - -> (UTxO, s) -utxoOurs tx = runState $ toUtxo <$> forM (zip [0..] (outputs tx)) filterOut - where - toUtxo = UTxO . Map.fromList . catMaybes - filterOut (ix, out) = do - state (isOurs $ address out) <&> \case - Just{} -> Just (TxIn (txId tx) ix, out) - Nothing -> Nothing + mconcat + <$> mapM (filterByAddressM isOurAddress . utxoFromTx) (Set.toList pending) diff --git a/lib/core/src/Cardano/Wallet/Primitive/Types/Address/Gen.hs b/lib/core/src/Cardano/Wallet/Primitive/Types/Address/Gen.hs index 7bbfddab89f..28c618345bc 100644 --- a/lib/core/src/Cardano/Wallet/Primitive/Types/Address/Gen.hs +++ b/lib/core/src/Cardano/Wallet/Primitive/Types/Address/Gen.hs @@ -1,6 +1,13 @@ module Cardano.Wallet.Primitive.Types.Address.Gen - ( genAddress + ( + -- * Generators and shrinkers + genAddress , shrinkAddress + , coarbitraryAddress + + -- * Indicator functions on addresses + , addressParity + , Parity (..) ) where @@ -9,8 +16,10 @@ import Prelude import Cardano.Wallet.Primitive.Types.Address ( Address (..) ) import Test.QuickCheck - ( Gen, elements, sized ) + ( Gen, coarbitrary, elements, sized ) +import qualified Data.Bits as Bits +import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as B8 -------------------------------------------------------------------------------- @@ -27,9 +36,53 @@ shrinkAddress a where simplest = head addresses +coarbitraryAddress :: Address -> Gen a -> Gen a +coarbitraryAddress = coarbitrary . BS.unpack . unAddress + addresses :: [Address] addresses = mkAddress <$> ['0' ..] +-------------------------------------------------------------------------------- +-- Indicator functions on addresses +-------------------------------------------------------------------------------- + +-- | Computes the parity of an address. +-- +-- Parity is defined in the following way: +-- +-- - even-parity address: +-- an address with a pop count (Hamming weight) that is even. +-- +-- - odd-parity address: +-- an address with a pop count (Hamming weight) that is odd. +-- +-- Examples of even-parity and odd-parity addresses: +-- +-- - 0b00000000 : even (Hamming weight = 0) +-- - 0b00000001 : odd (Hamming weight = 1) +-- - 0b00000010 : odd (Hamming weight = 1) +-- - 0b00000011 : even (Hamming weight = 2) +-- - 0b00000100 : odd (Hamming weight = 1) +-- - ... +-- - 0b11111110 : odd (Hamming weight = 7) +-- - 0b11111111 : even (Hamming weight = 8) +-- +addressParity :: Address -> Parity +addressParity = parity . addressPopCount + where + addressPopCount :: Address -> Int + addressPopCount = BS.foldl' (\acc -> (acc +) . Bits.popCount) 0 . unAddress + + parity :: Integral a => a -> Parity + parity a + | even a = Even + | otherwise = Odd + +-- | Represents the parity of a value (whether the value is even or odd). +-- +data Parity = Even | Odd + deriving (Eq, Show) + -------------------------------------------------------------------------------- -- Internal utilities -------------------------------------------------------------------------------- diff --git a/lib/core/src/Cardano/Wallet/Primitive/Types/Tx/Gen.hs b/lib/core/src/Cardano/Wallet/Primitive/Types/Tx/Gen.hs index 5f1302b9908..db59b671672 100644 --- a/lib/core/src/Cardano/Wallet/Primitive/Types/Tx/Gen.hs +++ b/lib/core/src/Cardano/Wallet/Primitive/Types/Tx/Gen.hs @@ -59,6 +59,7 @@ import Test.QuickCheck , liftArbitrary2 , liftShrink , liftShrink2 + , listOf , listOf1 , shrinkList , shrinkMapBy @@ -102,7 +103,7 @@ genTxWithoutId = TxWithoutId <$> liftArbitrary genCoinPositive <*> listOf1 (liftArbitrary2 genTxIn genCoinPositive) <*> listOf1 (liftArbitrary2 genTxIn genCoinPositive) - <*> listOf1 genTxOut + <*> listOf genTxOut <*> liftArbitrary genNestedTxMetadata <*> genMapWith genRewardAccount genCoinPositive diff --git a/lib/core/src/Cardano/Wallet/Primitive/Types/UTxO.hs b/lib/core/src/Cardano/Wallet/Primitive/Types/UTxO.hs index a04015f40f3..01d5cdd2283 100644 --- a/lib/core/src/Cardano/Wallet/Primitive/Types/UTxO.hs +++ b/lib/core/src/Cardano/Wallet/Primitive/Types/UTxO.hs @@ -3,6 +3,8 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedLabels #-} +{-# LANGUAGE RankNTypes #-} +{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilies #-} @@ -34,11 +36,15 @@ module Cardano.Wallet.Primitive.Types.UTxO , restrictedBy , restrictedTo , size + , filterByAddressM + , filterByAddress ) where import Prelude hiding ( null ) +import Cardano.Wallet.Primitive.Types.Address + ( Address ) import Cardano.Wallet.Primitive.Types.Coin ( Coin (..) ) import Cardano.Wallet.Primitive.Types.TokenBundle @@ -49,6 +55,8 @@ import Control.DeepSeq ( NFData (..) ) import Data.Bifunctor ( first ) +import Data.Functor.Identity + ( runIdentity ) import Data.Generics.Internal.VL.Lens ( view ) import Data.Kind @@ -136,6 +144,32 @@ null (UTxO u) = Map.null u size :: UTxO -> Int size (UTxO u) = Map.size u +-- | Filters a 'UTxO' set with an indicator function on 'Address' values. +-- +-- Returns the subset of UTxO entries that have addresses for which the given +-- indicator function returns 'True'. +filterByAddressM :: forall f. Monad f => (Address -> f Bool) -> UTxO -> f UTxO +filterByAddressM isOursF (UTxO m) = + UTxO <$> Map.traverseMaybeWithKey filterFunc m + where + filterFunc :: TxIn -> TxOut -> f (Maybe TxOut) + filterFunc _txin txout = do + ours <- isOursF $ view #address txout + pure $ if ours then Just txout else Nothing + +-- | Filters a 'UTxO' set with an indicator function on 'Address' values. +-- +-- Returns the subset of UTxO entries that have addresses for which the given +-- indicator function returns 'True'. +-- +-- filterByAddress f u = runIdentity $ filterByAddressM (pure . f) u +-- filterByAddress (const True) u = u +-- filterByAddress (const False) u = mempty +-- filterByAddress f mempty = mempty +-- filterByAddress f u `isSubsetOf` u +filterByAddress :: (Address -> Bool) -> UTxO -> UTxO +filterByAddress f = runIdentity . filterByAddressM (pure . f) + data UTxOStatistics = UTxOStatistics { histogram :: ![HistogramBar] , allStakes :: !Word64 diff --git a/lib/core/test/unit/Cardano/Wallet/Primitive/ModelSpec.hs b/lib/core/test/unit/Cardano/Wallet/Primitive/ModelSpec.hs index 258dfca0b7f..ed13397d0f3 100644 --- a/lib/core/test/unit/Cardano/Wallet/Primitive/ModelSpec.hs +++ b/lib/core/test/unit/Cardano/Wallet/Primitive/ModelSpec.hs @@ -17,6 +17,8 @@ module Cardano.Wallet.Primitive.ModelSpec import Prelude +import Algebra.PartialOrd + ( PartialOrd (..) ) import Cardano.Wallet.DummyTarget.Primitive.Types ( block0 ) import Cardano.Wallet.Primitive.AddressDerivation @@ -27,16 +29,19 @@ import Cardano.Wallet.Primitive.Model ( Wallet , applyBlock , applyBlocks + , applyTxToUTxO , availableBalance , availableUTxO , changeUTxO , currentTip , getState , initWallet + , spendTx , totalBalance , totalUTxO , unsafeInitWallet , utxo + , utxoFromTx ) import Cardano.Wallet.Primitive.Slotting.Legacy ( flatSlot ) @@ -52,7 +57,7 @@ import Cardano.Wallet.Primitive.Types import Cardano.Wallet.Primitive.Types.Address ( Address (..) ) import Cardano.Wallet.Primitive.Types.Address.Gen - ( genAddress ) + ( Parity (..), addressParity, coarbitraryAddress ) import Cardano.Wallet.Primitive.Types.Coin ( Coin (..) ) import Cardano.Wallet.Primitive.Types.Hash @@ -67,13 +72,14 @@ import Cardano.Wallet.Primitive.Types.Tx , TxIn (..) , TxMeta (direction) , TxOut (..) + , inputs , txIns , txOutCoin ) import Cardano.Wallet.Primitive.Types.Tx.Gen ( genTx, shrinkTx ) import Cardano.Wallet.Primitive.Types.UTxO - ( Dom (..), UTxO (..), balance, excluding, restrictedTo ) + ( Dom (..), UTxO (..), balance, excluding, filterByAddress, restrictedTo ) import Cardano.Wallet.Primitive.Types.UTxO.Gen ( genUTxO, shrinkUTxO ) import Control.DeepSeq @@ -97,7 +103,7 @@ import Data.List import Data.List.NonEmpty ( NonEmpty (..) ) import Data.Maybe - ( catMaybes ) + ( catMaybes, isJust ) import Data.Quantity ( Quantity (..) ) import Data.Set @@ -116,6 +122,7 @@ import Test.Hspec.Extra ( parallel ) import Test.QuickCheck ( Arbitrary (..) + , CoArbitrary (..) , Gen , Positive (..) , Property @@ -127,7 +134,6 @@ import Test.QuickCheck , counterexample , cover , elements - , forAll , forAllShrink , frequency , genericShrink @@ -144,7 +150,6 @@ import Test.QuickCheck import qualified Cardano.Wallet.Primitive.Types.TokenBundle as TokenBundle import qualified Cardano.Wallet.Primitive.Types.UTxO as UTxO -import qualified Data.Bits as Bits import qualified Data.ByteString as BS import qualified Data.Foldable as F import qualified Data.List as L @@ -183,6 +188,40 @@ spec = do it "only counts rewards once." (property prop_countRewardsOnce) + describe "coverage" $ do + it "utxo and tx generators have expected coverage" + (property prop_tx_utxo_coverage) + + describe "applyTxToUTxO" $ do + it "has expected balance" + (property prop_applyTxToUTxO_balance) + it "has expected entries" + (property prop_applyTxToUTxO_entries) + it "applyTxToUTxO then filterByAddress" + (property prop_filterByAddress_balance_applyTxToUTxO) + it "spendTx/applyTxToUTxO/utxoFromTx" + (property prop_applyTxToUTxO_spendTx_utxoFromTx) + + describe "utxoFromTx" $ do + it "has expected balance" + (property prop_utxoFromTx_balance) + it "is unspent" + (property prop_utxoFromTx_is_unspent) + + describe "spendTx" $ do + it "is subset of UTxO" + (property prop_spendTx_isSubset) + it "balance is <= balance of UTxO" + (property prop_spendTx_balance_inequality) + it "has expected balance" + (property prop_spendTx_balance) + it "definition" + (property prop_spendTx) + it "commutative with filterByAddress" + (property prop_spendTx_filterByAddress) + it "spendTx/utxoFromTx" + (property prop_spendTx_utxoFromTx) + parallel $ describe "Available UTxO" $ do it "prop_availableUTxO_isSubmap" $ property prop_availableUTxO_isSubmap @@ -196,8 +235,6 @@ spec = do parallel $ describe "Change UTxO" $ do it "prop_changeUTxO" $ property prop_changeUTxO - it "prop_addressParity_coverage" $ - property prop_addressParity_coverage parallel $ describe "Total UTxO" $ do it "prop_totalUTxO_pendingChangeIncluded" $ @@ -479,55 +516,6 @@ prop_changeUTxO_inner pendingTxs = pendingTxSet :: Set Tx pendingTxSet = Set.fromList pendingTxs --- | Represents the parity of a value (whether the value is even or odd). --- -data Parity = Even | Odd - deriving (Eq, Show) - --- | Computes the parity of an address. --- --- Parity is defined in the following way: --- --- - even-parity address: --- an address with a pop count (Hamming weight) that is even. --- --- - odd-parity address: --- an address with a pop count (Hamming weight) that is odd. --- --- Examples of even-parity and odd-parity addresses: --- --- - 0b00000000 : even (Hamming weight = 0) --- - 0b00000001 : odd (Hamming weight = 1) --- - 0b00000010 : odd (Hamming weight = 1) --- - 0b00000011 : even (Hamming weight = 2) --- - 0b00000100 : odd (Hamming weight = 1) --- - ... --- - 0b11111110 : odd (Hamming weight = 7) --- - 0b11111111 : even (Hamming weight = 8) --- -addressParity :: Address -> Parity -addressParity = parity . addressPopCount - where - addressPopCount :: Address -> Int - addressPopCount = BS.foldl' (\acc -> (acc +) . Bits.popCount) 0 . unAddress - - parity :: Integral a => a -> Parity - parity a - | even a = Even - | otherwise = Odd - --- | Verifies that addresses are generated with both even and odd parity. --- -prop_addressParity_coverage :: Property -prop_addressParity_coverage = - forAll genAddress $ \addr -> - checkCoverage $ - cover 40 (addressParity addr == Even) - "address parity is even" $ - cover 40 (addressParity addr == Odd) - "address parity is odd" $ - property True - -- | Encapsulates a filter condition for matching entities with 'IsOurs'. -- newtype IsOursIf a = IsOursIf {condition :: a -> Bool} @@ -704,13 +692,6 @@ txOutsOurs txs = forMaybe :: Monad m => [a] -> (a -> m (Maybe b)) -> m [b] forMaybe xs = fmap catMaybes . for xs --- | Construct a UTxO corresponding to a given transaction. It is important for --- the transaction outputs to be ordered correctly, since they become available --- inputs for the subsequent blocks. -utxoFromTx :: Tx -> UTxO -utxoFromTx Tx {txId, outputs} = - UTxO $ Map.fromList $ zip (TxIn txId <$> [0..]) outputs - {------------------------------------------------------------------------------- Test Data @@ -760,6 +741,14 @@ instance IsOurs WalletState RewardAccount where , s ) +instance Arbitrary Tx where + shrink = shrinkTx + arbitrary = genTx + +instance Arbitrary UTxO where + shrink = shrinkUTxO + arbitrary = genUTxO + instance Arbitrary WalletState where shrink = genericShrink arbitrary = do @@ -1527,3 +1516,152 @@ blockchain = ] where slot e s = SlotNo $ flatSlot (EpochLength 21600) (SlotId e s) + +prop_tx_utxo_coverage :: Tx -> UTxO -> Property +prop_tx_utxo_coverage tx u = + checkCoverage $ + cover 2 (UTxO.null u) + "UTxO empty" $ + cover 30 (not $ UTxO.null u) + "UTxO not empty" $ + cover 30 (not $ Set.disjoint (dom u) (Set.fromList $ inputs tx)) + "UTxO and Tx not disjoint" $ + cover 10 (Set.disjoint (dom u) (Set.fromList $ inputs tx)) + "UTxO and Tx disjoint" $ + cover 4 (length (inputs tx) > 3) + "Number of tx inputs > 3" $ + cover 4 (length (inputs tx) < 3) + "Number of tx inputs < 3" $ + cover 4 (length (outputs tx) > 3) + "Number of tx outputs > 3" $ + cover 4 (length (outputs tx) < 3) + "Number of tx outputs < 3" $ + property True + +prop_applyTxToUTxO_balance :: Tx -> UTxO -> Property +prop_applyTxToUTxO_balance tx u = + checkCoverage $ + cover 0.1 + (applyTxToUTxO tx u == u) + "applyTxToUTxO tx u == u" $ + cover 10 + (applyTxToUTxO tx u /= u) + "applyTxToUTxO tx u /= u" $ + balance (applyTxToUTxO tx u) + === balance u + `TokenBundle.add` + balance (utxoFromTx tx) + `TokenBundle.difference` + balance (u `UTxO.restrictedBy` Set.fromList (inputs tx)) + +prop_applyTxToUTxO_entries :: Tx -> UTxO -> Property +prop_applyTxToUTxO_entries tx u = + checkCoverage $ + cover 0.1 + (applyTxToUTxO tx u == u) + "applyTxToUTxO tx u == u" $ + cover 10 + (applyTxToUTxO tx u /= u) + "applyTxToUTxO tx u /= u" $ + unUTxO (applyTxToUTxO tx u) + === unUTxO u + `Map.union` + unUTxO (utxoFromTx tx) + `Map.difference` + unUTxO (u `UTxO.restrictedBy` Set.fromList (inputs tx)) + +prop_filterByAddress_balance_applyTxToUTxO + :: (Address -> Bool) -> Tx -> Property +prop_filterByAddress_balance_applyTxToUTxO f tx = + checkCoverage $ + cover 0.1 + (filterByAddress f (applyTxToUTxO tx mempty) == mempty) + "filterByAddress f (applyTxToUTxO tx mempty) == mempty" $ + cover 10 + (filterByAddress f (applyTxToUTxO tx mempty) /= mempty) + "filterByAddress f (applyTxToUTxO tx mempty) /= mempty" $ + balance (filterByAddress f (applyTxToUTxO tx mempty)) + === foldMap + (\o -> if f (address o) then tokens o else mempty) + (outputs tx) + +prop_utxoFromTx_balance :: Tx -> Property +prop_utxoFromTx_balance tx = + balance (utxoFromTx tx) === foldMap tokens (outputs tx) + +prop_utxoFromTx_is_unspent :: Tx -> Property +prop_utxoFromTx_is_unspent tx = + utxoFromTx tx `excluding` Set.fromList (inputs tx) + === utxoFromTx tx + +-- spendTx tx u `isSubsetOf` u +prop_spendTx_isSubset :: Tx -> UTxO -> Property +prop_spendTx_isSubset tx u = + checkCoverage $ + cover 10 isNonEmptyProperSubmap "isNonEmptyProperSubmap" $ + property $ spendTx tx u `UTxO.isSubsetOf` u + where + isNonEmptyProperSubmap = (&&) + (spendTx tx u /= mempty) + (unUTxO (spendTx tx u) `Map.isProperSubmapOf` unUTxO u) + +-- balance (spendTx tx u) <= balance u +prop_spendTx_balance_inequality :: Tx -> UTxO -> Property +prop_spendTx_balance_inequality tx u = + checkCoverage $ + cover 10 + (lhs /= mempty && lhs `leq` rhs && lhs /= rhs) + "lhs /= mempty && lhs `leq` rhs && lhs /= rhs" $ + isJust (rhs `TokenBundle.subtract` lhs) + & counterexample ("balance (spendTx tx u) = " <> show lhs) + & counterexample ("balance u = " <> show rhs) + where + lhs = balance (spendTx tx u) + rhs = balance u + +prop_spendTx_balance :: Tx -> UTxO -> Property +prop_spendTx_balance tx u = + checkCoverage $ + cover 10 + (lhs /= mempty && rhs /= mempty) + "lhs /= mempty && rhs /= mempty" $ + lhs === rhs + where + lhs = balance (spendTx tx u) + rhs = TokenBundle.unsafeSubtract + (balance u) + (balance (u `UTxO.restrictedBy` Set.fromList (inputs tx))) + +prop_spendTx :: Tx -> UTxO -> Property +prop_spendTx tx u = + checkCoverage $ + cover 10 + (spendTx tx u /= mempty) + "spendTx tx u /= mempty" $ + spendTx tx u === u `excluding` Set.fromList (inputs tx) + +prop_spendTx_utxoFromTx :: Tx -> UTxO -> Property +prop_spendTx_utxoFromTx tx u = + spendTx tx (u <> utxoFromTx tx) === spendTx tx u <> utxoFromTx tx + +prop_applyTxToUTxO_spendTx_utxoFromTx :: Tx -> UTxO -> Property +prop_applyTxToUTxO_spendTx_utxoFromTx tx u = + checkCoverage $ + cover 10 + (spendTx tx u /= mempty && utxoFromTx tx /= mempty) + "spendTx tx u /= mempty && utxoFromTx tx /= mempty" $ + applyTxToUTxO tx u === spendTx tx u <> utxoFromTx tx + +prop_spendTx_filterByAddress :: (Address -> Bool) -> Tx -> UTxO -> Property +prop_spendTx_filterByAddress f tx u = + checkCoverage $ + cover 10 + (spendTx tx u /= mempty && filterByAddress f u /= mempty) + "spendTx tx u /= mempty && filterByAddress f u /= mempty" $ + filterByAddress f (spendTx tx u) === spendTx tx (filterByAddress f u) + +instance CoArbitrary Address where + coarbitrary = coarbitraryAddress + +instance Show (Address -> Bool) where + show = const "(Address -> Bool)" diff --git a/lib/core/test/unit/Cardano/Wallet/Primitive/Types/AddressSpec.hs b/lib/core/test/unit/Cardano/Wallet/Primitive/Types/AddressSpec.hs new file mode 100644 index 00000000000..8f872642fb2 --- /dev/null +++ b/lib/core/test/unit/Cardano/Wallet/Primitive/Types/AddressSpec.hs @@ -0,0 +1,33 @@ +module Cardano.Wallet.Primitive.Types.AddressSpec + ( spec + ) where + +import Prelude + +import Cardano.Wallet.Primitive.Types.Address.Gen + ( Parity (..), addressParity, genAddress ) +import Test.Hspec + ( Spec, describe, it ) +import Test.QuickCheck + ( Property, checkCoverage, cover, forAll, property ) + +spec :: Spec +spec = + describe "Cardano.Wallet.Primitive.Types.AddressSpec" $ do + + describe "addressParity" $ do + + it "prop_addressParity_coverage" $ + property prop_addressParity_coverage + +-- | Verifies that addresses are generated with both even and odd parity. +-- +prop_addressParity_coverage :: Property +prop_addressParity_coverage = + forAll genAddress $ \addr -> + checkCoverage $ + cover 40 (addressParity addr == Even) + "address parity is even" $ + cover 40 (addressParity addr == Odd) + "address parity is odd" $ + property True diff --git a/lib/core/test/unit/Cardano/Wallet/Primitive/Types/UTxOSpec.hs b/lib/core/test/unit/Cardano/Wallet/Primitive/Types/UTxOSpec.hs new file mode 100644 index 00000000000..a116f0f0a84 --- /dev/null +++ b/lib/core/test/unit/Cardano/Wallet/Primitive/Types/UTxOSpec.hs @@ -0,0 +1,126 @@ +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE OverloadedLabels #-} +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Cardano.Wallet.Primitive.Types.UTxOSpec + ( spec + ) where + +import Prelude + +import Cardano.Wallet.Primitive.Types.Address + ( Address (..) ) +import Cardano.Wallet.Primitive.Types.Address.Gen + ( Parity (..), addressParity, coarbitraryAddress ) +import Cardano.Wallet.Primitive.Types.UTxO + ( UTxO (..), dom, filterByAddress, filterByAddressM, isSubsetOf ) +import Cardano.Wallet.Primitive.Types.UTxO.Gen + ( genUTxO, shrinkUTxO ) +import Data.Functor.Identity + ( runIdentity ) +import Data.Generics.Internal.VL.Lens + ( view ) +import Test.Hspec + ( Spec, describe, it ) +import Test.QuickCheck + ( Arbitrary (..) + , CoArbitrary (..) + , Property + , checkCoverage + , conjoin + , cover + , property + , (===) + ) + +import qualified Data.Map.Strict as Map +import qualified Data.Set as Set + +spec :: Spec +spec = + describe "Cardano.Wallet.Primitive.Types.UTxOSpec" $ do + + describe "filterByAddress" $ do + it "matching everything gives us everything" $ + property prop_filterByAddress_matchAll + it "matching nothing gives us nothing" $ + property prop_filterByAddress_matchNone + it "matching some addresses gives us the appropriate subset" $ + property prop_filterByAddress_matchSome + it "if there are no utxos, the result utxo should be empty" $ + property prop_filterByAddress_empty + it "filterByAddress/filterByAddressM" $ + property prop_filterByAddress_filterByAddressM + it "filterByAddress is always subset" $ + property prop_filterByAddress_isSubset + +prop_filterByAddress_matchAll :: UTxO -> Property +prop_filterByAddress_matchAll u = + checkCoverage $ + cover 2 (u == mempty) "empty" $ + cover 8 (u /= mempty) "non-empty" $ + filterByAddress (const True) u === u + +prop_filterByAddress_matchNone :: UTxO -> Property +prop_filterByAddress_matchNone u = + checkCoverage $ + cover 2 (u == mempty) "empty" $ + cover 8 (u /= mempty) "non-empty" $ + filterByAddress (const False) u === mempty + +prop_filterByAddress_matchSome :: UTxO -> Property +prop_filterByAddress_matchSome utxo = + checkCoverage $ + cover 10 + (domEven /= mempty && domEven `Set.isProperSubsetOf` dom utxo) + "domEven /= mempty && domEven `Set.isProperSubsetOf` dom utxo" $ + cover 10 + (domOdd /= mempty && domOdd `Set.isProperSubsetOf` dom utxo) + "domOdd /= mempty && domOdd `Set.isProperSubsetOf` dom utxo" $ + conjoin + [ utxoEven <> utxoOdd == utxo + , unUTxO utxoEven `Map.isSubmapOf` unUTxO utxo + , unUTxO utxoOdd `Map.isSubmapOf` unUTxO utxo + , all ((== Even) . addressParity . view #address) (unUTxO utxoEven) + , all ((== Odd) . addressParity . view #address) (unUTxO utxoOdd) + ] + where + domEven = dom utxoEven + domOdd = dom utxoOdd + + utxoEven = filterByAddress ((== Even) . addressParity) utxo + utxoOdd = filterByAddress ((== Odd) . addressParity) utxo + +prop_filterByAddress_empty :: (Address -> Bool) -> Property +prop_filterByAddress_empty f = + filterByAddress f mempty === mempty + +prop_filterByAddress_filterByAddressM :: UTxO -> (Address -> Bool) -> Property +prop_filterByAddress_filterByAddressM u f = + checkCoverage $ + cover 10 isNonEmptyProperSubset "is non-empty proper subset" $ + filterByAddress f u === runIdentity (filterByAddressM (pure . f) u) + where + isNonEmptyProperSubset = (&&) + (filterByAddress f u /= mempty) + (dom (filterByAddress f u) `Set.isProperSubsetOf` dom u) + +prop_filterByAddress_isSubset :: UTxO -> (Address -> Bool) -> Property +prop_filterByAddress_isSubset u f = + checkCoverage $ + cover 10 isNonEmptyProperSubset "is non-empty proper subset" $ + property $ filterByAddress f u `isSubsetOf` u + where + isNonEmptyProperSubset = (&&) + (filterByAddress f u /= mempty) + (dom (filterByAddress f u) `Set.isProperSubsetOf` dom u) + +instance CoArbitrary Address where + coarbitrary = coarbitraryAddress + +instance Show (Address -> Bool) where + show = const "(Address -> Bool)" + +instance Arbitrary UTxO where + arbitrary = genUTxO + shrink = shrinkUTxO