diff --git a/plutus-core/plutus-core/src/PlutusCore/Builtin/Result.hs b/plutus-core/plutus-core/src/PlutusCore/Builtin/Result.hs index ee47e0f2af2..cd0e92d9027 100644 --- a/plutus-core/plutus-core/src/PlutusCore/Builtin/Result.hs +++ b/plutus-core/plutus-core/src/PlutusCore/Builtin/Result.hs @@ -1,12 +1,10 @@ -- editorconfig-checker-disable-file -{-# LANGUAGE FlexibleInstances #-} -{-# LANGUAGE FunctionalDependencies #-} -{-# LANGUAGE LambdaCase #-} -{-# LANGUAGE MultiParamTypeClasses #-} -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE RankNTypes #-} -{-# LANGUAGE StrictData #-} -{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE MultiParamTypeClasses #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RankNTypes #-} +{-# LANGUAGE StrictData #-} module PlutusCore.Builtin.Result ( EvaluationError (..) diff --git a/plutus-core/plutus-core/src/PlutusCore/Default/Builtins.hs b/plutus-core/plutus-core/src/PlutusCore/Default/Builtins.hs index e9f738f465b..b159f2011bb 100644 --- a/plutus-core/plutus-core/src/PlutusCore/Default/Builtins.hs +++ b/plutus-core/plutus-core/src/PlutusCore/Default/Builtins.hs @@ -2063,7 +2063,7 @@ instance uni ~ DefaultUni => ToBuiltinMeaning uni DefaultFun where (runCostingFunThreeArguments . unimplementedCostingFun) toBuiltinMeaning _semvar UnionValue = - let unionValueDenotation :: Value -> Value -> Value + let unionValueDenotation :: Value -> Value -> BuiltinResult Value unionValueDenotation = Value.unionValue {-# INLINE unionValueDenotation #-} in makeBuiltinMeaning diff --git a/plutus-core/plutus-core/src/PlutusCore/Parser/Builtin.hs b/plutus-core/plutus-core/src/PlutusCore/Parser/Builtin.hs index a35178aaac8..0cffcc5864e 100644 --- a/plutus-core/plutus-core/src/PlutusCore/Parser/Builtin.hs +++ b/plutus-core/plutus-core/src/PlutusCore/Parser/Builtin.hs @@ -5,6 +5,7 @@ module PlutusCore.Parser.Builtin where import PlutusPrelude (Word8, reoption, void) +import PlutusCore.Builtin.Result qualified import PlutusCore.Crypto.BLS12_381.G1 qualified as BLS12_381.G1 import PlutusCore.Crypto.BLS12_381.G2 qualified as BLS12_381.G2 import PlutusCore.Data @@ -91,13 +92,22 @@ conArray uniA = Vector.fromList <$> conList uniA -- | Parser for values. conValue :: Parser PLC.Value conValue = do - Value.fromList <$> (traverse validateKeys =<< conList knownUni) + keys <- traverse validateKeys =<< conList knownUni + case Value.fromList keys of + PlutusCore.Builtin.Result.BuiltinSuccess v -> pure v + PlutusCore.Builtin.Result.BuiltinSuccessWithLogs _logs v -> pure v + PlutusCore.Builtin.Result.BuiltinFailure logs _trace -> + fail $ "Failed to construct Value: " <> show logs where validateToken (token, amt) = do - tk <- maybe (fail $ "Invalid token: " <> show (unpack token)) pure (Value.k token) - pure (tk, amt) + tk <- maybe (fail $ "Token name exceeds maximum length of 32 bytes: " <> show (unpack token)) + pure (Value.k token) + qty <- maybe (fail $ "Token quantity out of signed 128-bit integer bounds: " <> show amt) + pure (Value.quantity amt) + pure (tk, qty) validateKeys (currency, tokens) = do - ck <- maybe (fail $ "Invalid currency: " <> show (unpack currency)) pure (Value.k currency) + ck <- maybe (fail $ "Currency symbol exceeds maximum length of 32 bytes: " <> show (unpack currency)) + pure (Value.k currency) tks <- traverse validateToken tokens pure (ck, tks) diff --git a/plutus-core/plutus-core/src/PlutusCore/Pretty/PrettyConst.hs b/plutus-core/plutus-core/src/PlutusCore/Pretty/PrettyConst.hs index 38d53ef8d8c..a7001386add 100644 --- a/plutus-core/plutus-core/src/PlutusCore/Pretty/PrettyConst.hs +++ b/plutus-core/plutus-core/src/PlutusCore/Pretty/PrettyConst.hs @@ -113,7 +113,7 @@ instance NonDefaultPrettyBy ConstConfig T.Text where nonDefaultPrettyListBy conf = Prettyprinter.list . Prelude.map (nonDefaultPrettyBy conf) nonDefaultPrettyBy = inContextM $ \t -> pure $ pretty $ "\"" <> escape t <> "\"" where - escape t = T.foldr' prettyChar "" t + escape = T.foldr' prettyChar "" prettyChar c acc | c == '"' = "\\\"" <> acc -- Not handled by 'showLitChar' | c == '\\' = "\\\\" <> acc -- Not handled by 'showLitChar' @@ -162,6 +162,9 @@ instance PrettyBy ConstConfig Data where instance PrettyBy ConstConfig Value.K where prettyBy config = prettyBy config . Value.unK +instance PrettyBy ConstConfig Value.Quantity where + prettyBy config = prettyBy config . Value.unQuantity + instance PrettyBy ConstConfig Value where prettyBy config = prettyBy config . Value.toList diff --git a/plutus-core/plutus-core/src/PlutusCore/Value.hs b/plutus-core/plutus-core/src/PlutusCore/Value.hs index f8c9a7be123..005942549af 100644 --- a/plutus-core/plutus-core/src/PlutusCore/Value.hs +++ b/plutus-core/plutus-core/src/PlutusCore/Value.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE BlockArguments #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE LambdaCase #-} @@ -10,6 +11,11 @@ module PlutusCore.Value ( k, unK, maxKeyLen, + Quantity, -- Do not expose data constructor + quantity, + unQuantity, + zeroQuantity, + addQuantity, negativeAmounts, NestedMap, unpack, @@ -36,6 +42,7 @@ import Data.Bitraversable import Data.ByteString (ByteString) import Data.ByteString qualified as B import Data.ByteString.Base64 qualified as Base64 +import Data.Foldable (find) import Data.Hashable (Hashable (..)) import Data.IntMap.Strict (IntMap) import Data.IntMap.Strict qualified as IntMap @@ -45,6 +52,7 @@ import Data.Map.Strict qualified as Map import Data.Monoid (All (..)) import Data.Text.Encoding qualified as Text import GHC.Generics +import GHC.Stack (HasCallStack, callStack, getCallStack) import PlutusCore.Builtin.Result import PlutusCore.Data (Data (..)) @@ -58,6 +66,9 @@ maxKeyLen :: Int maxKeyLen = 32 {-# INLINE maxKeyLen #-} +---------------------------------------------------------------------------------------------------- +-- Newtype-wrapper for keys used in the nested maps ------------------------------------------------ + -- | A `ByteString` with maximum length of `maxKeyLen` bytes. newtype K = UnsafeK {unK :: ByteString} deriving newtype (Eq, Ord, Show, Hashable, NFData) @@ -83,15 +94,72 @@ instance CBOR.Serialise K where maybe (fail $ "Invalid Value key: " <> show (B.unpack b)) pure (k b) {-# INLINEABLE decode #-} -type NestedMap = Map K (Map K Integer) +---------------------------------------------------------------------------------------------------- +-- Quantity: Signed 128-bit Integer ---------------------------------------------------------------- + +-- | A signed 128-bit integer quantity. +newtype Quantity = UnsafeQuantity {unQuantity :: Integer} + deriving newtype (Eq, Ord, Show, NFData, Hashable) + deriving stock (Generic) + +instance CBOR.Serialise Quantity where + encode (UnsafeQuantity i) = CBOR.encode i + {-# INLINE encode #-} + decode = do + i <- CBOR.decode + case quantity i of + Just q -> pure q + Nothing -> fail $ "Quantity out of signed 128-bit integer bounds: " <> show i + {-# INLINEABLE decode #-} + +instance Flat.Flat Quantity where + encode (UnsafeQuantity i) = Flat.encode i + {-# INLINE encode #-} + decode = do + i <- Flat.decode + case quantity i of + Just q -> pure q + Nothing -> fail $ "Quantity out of signed 128-bit integer bounds: " <> show i + {-# INLINEABLE decode #-} + +instance Pretty Quantity where + pretty (UnsafeQuantity i) = pretty i + +instance Bounded Quantity where + minBound = UnsafeQuantity (-(2 ^ (127 :: Integer))) + {-# INLINE minBound #-} + maxBound = UnsafeQuantity (2 ^ (127 :: Integer) - 1) + {-# INLINE maxBound #-} + +-- | Smart constructor for Quantity that validates bounds. +quantity :: Integer -> Maybe Quantity +quantity i + | i >= unQuantity minBound && i <= unQuantity maxBound = Just (UnsafeQuantity i) + | otherwise = Nothing +{-# INLINEABLE quantity #-} + +-- | The zero quantity. +zeroQuantity :: Quantity +zeroQuantity = UnsafeQuantity 0 +{-# INLINE zeroQuantity #-} + +-- | Safely add two quantities, checking for overflow. +addQuantity :: Quantity -> Quantity -> Maybe Quantity +addQuantity (UnsafeQuantity x) (UnsafeQuantity y) = quantity (x + y) +{-# INLINEABLE addQuantity #-} + +---------------------------------------------------------------------------------------------------- +-- Builtin Value definition ------------------------------------------------------------------------ + +type NestedMap = Map K (Map K Quantity) -- | The underlying type of the UPLC built-in type @Value@. data Value = Value !NestedMap - {- ^ Map from (currency symbol, token name) to amount. + {- ^ Map from (currency symbol, token name) to quantity. - Invariants: no empty inner map, and no zero amount. + Invariants: no empty inner map, and no zero quantity. -} !(IntMap Int) {- ^ Map from size to the number of inner maps that have that size. @@ -127,17 +195,17 @@ instance Flat.Flat Value where decode = pack <$> Flat.decode {-# INLINE decode #-} -{-| Unpack a `Value` into a map from (currency symbol, token name) to amount. +{-| Unpack a `Value` into a map from (currency symbol, token name) to quantity. -The map is guaranteed to not contain empty inner map or zero amount. +The map is guaranteed to not contain empty inner map or zero quantity. -} unpack :: Value -> NestedMap unpack (Value v _ _ _) = v {-# INLINE unpack #-} -{-| Pack a map from (currency symbol, token name) to amount into a `Value`. +{-| Pack a map from (currency symbol, token name) to quantity into a `Value`. -The map will be filtered so that it does not contain empty inner map or zero amount. +The map will be filtered so that it does not contain empty inner map or zero quantity. -} pack :: NestedMap -> Value pack = pack' . normalize @@ -151,7 +219,7 @@ pack' v = Value v sizes size neg alg (ss, s, n) inner = ( IntMap.alter (maybe (Just 1) (Just . (+ 1))) (Map.size inner) ss , s + Map.size inner - , n + Map.size (Map.filter (< 0) inner) + , n + Map.size (Map.filter (< zeroQuantity) inner) ) {-# INLINEABLE pack' #-} @@ -175,23 +243,47 @@ empty :: Value empty = Value mempty mempty 0 0 {-# INLINE empty #-} -toList :: Value -> [(K, [(K, Integer)])] +toList :: Value -> [(K, [(K, Quantity)])] toList = Map.toList . Map.map Map.toList . unpack {-# INLINEABLE toList #-} -toFlatList :: Value -> [(K, K, Integer)] +toFlatList :: Value -> [(K, K, Quantity)] toFlatList (toList -> xs) = [(c, t, a) | (c, ys) <- xs, (t, a) <- ys] {-# INLINEABLE toFlatList #-} -fromList :: [(K, [(K, Integer)])] -> Value -fromList = - pack - . Map.fromListWith (Map.unionWith (+)) - . fmap (second (Map.fromListWith (+))) +fromList :: [(K, [(K, Quantity)])] -> BuiltinResult Value +fromList xs = do + -- Use unchecked addition during construction + let outerMap = + Map.fromListWith + (Map.unionWith unsafeAddQuantity) -- combine inner maps with unchecked addition + (second (Map.fromListWith unsafeAddQuantity) <$> xs) + -- Validate all quantities are within bounds + pack <$> validateQuantities outerMap {-# INLINEABLE fromList #-} +-- | Unsafe addition of quantities without bounds checking. +unsafeAddQuantity :: Quantity -> Quantity -> Quantity +unsafeAddQuantity (UnsafeQuantity x) (UnsafeQuantity y) = UnsafeQuantity (x + y) +{-# INLINE unsafeAddQuantity #-} + +-- | Validate all quantities in a nested map are within bounds. +validateQuantities :: HasCallStack => NestedMap -> BuiltinResult NestedMap +validateQuantities nestedMap = + case find isOutOfBounds allQuantities of + Just (UnsafeQuantity i) -> fail $ context <> ": quantity out of bounds: " <> show i + Nothing -> pure nestedMap + where + allQuantities = concatMap Map.elems $ Map.elems nestedMap + isOutOfBounds (UnsafeQuantity i) = + i < unQuantity minBound || i > unQuantity maxBound + context = case getCallStack callStack of + (fnName, _):_ -> fnName + [] -> "" +{-# INLINEABLE validateQuantities #-} + normalize :: NestedMap -> NestedMap -normalize = Map.filter (not . Map.null) . Map.map (Map.filter (/= 0)) +normalize = Map.filter (not . Map.null) . Map.map (Map.filter (/= zeroQuantity)) {-# INLINEABLE normalize #-} instance Pretty Value where @@ -203,39 +295,40 @@ instance Pretty Value where the size of the largest inner map. -} insertCoin :: ByteString -> ByteString -> Integer -> Value -> BuiltinResult Value -insertCoin currency token amt v@(Value outer sizes size neg) - | amt == 0 = pure $ deleteCoin currency token v - | otherwise = case (k currency, k token) of - (Nothing, _) -> fail $ "insertCoin: invalid currency: " <> show (B.unpack currency) - (_, Nothing) -> fail $ "insertCoin: invalid token: " <> show (B.unpack token) - (Just ck, Just tk) -> +insertCoin unsafeCurrency unsafeToken unsafeAmount v@(Value outer sizes size neg) + | unsafeAmount == 0 = pure $ deleteCoin unsafeCurrency unsafeToken v + | otherwise = case (k unsafeCurrency, k unsafeToken, quantity unsafeAmount) of + (Nothing, _, _) -> fail $ "insertCoin: invalid currency: " <> show (B.unpack unsafeCurrency) + (_, Nothing, _) -> fail $ "insertCoin: invalid token: " <> show (B.unpack unsafeToken) + (_, _, Nothing) -> fail $ "insertCoin: quantity out of bounds: " <> show unsafeAmount + (Just currency, Just token, Just qty) -> let f - :: Maybe (Map K Integer) + :: Maybe (Map K Quantity) -> ( -- Left (old size of inner map) if the total size grows by 1, - -- otherwise, Right (old amount) - Either Int Integer - , Maybe (Map K Integer) + -- otherwise, Right (old quantity) + Either Int Quantity + , Maybe (Map K Quantity) ) f = \case - Nothing -> (Left 0, Just (Map.singleton tk amt)) + Nothing -> (Left 0, Just (Map.singleton token qty)) Just inner -> - let (moldAmt, inner') = - Map.insertLookupWithKey (\_ _ _ -> amt) tk amt inner - in (maybe (Left (Map.size inner)) Right moldAmt, Just inner') - (res, outer') = Map.alterF f ck outer + let (mOldQuantity, inner') = + Map.insertLookupWithKey (\_ _ _ -> qty) token qty inner + in (maybe (Left (Map.size inner)) Right mOldQuantity, Just inner') + (res, outer') = Map.alterF f currency outer (sizes', size', neg') = case res of Left oldSize -> ( updateSizes oldSize (oldSize + 1) sizes , size + 1 - , if amt < 0 then neg + 1 else neg + , if qty < zeroQuantity then neg + 1 else neg ) - Right oldAmt -> + Right oldQuantity -> ( sizes , size - , if oldAmt < 0 && amt > 0 + , if oldQuantity < zeroQuantity && qty > zeroQuantity then neg - 1 else - if oldAmt > 0 && amt < 0 + if oldQuantity > zeroQuantity && qty < zeroQuantity then neg + 1 else neg ) @@ -249,74 +342,76 @@ deleteCoin (UnsafeK -> currency) (UnsafeK -> token) (Value outer sizes size neg) where (mold, outer') = Map.alterF f currency outer (sizes', size', neg') = case mold of - Just (oldSize, oldAmt) -> + Just (oldSize, oldQuantity) -> ( updateSizes oldSize (oldSize - 1) sizes , size - 1 - , if oldAmt < 0 then neg - 1 else neg + , if oldQuantity < zeroQuantity then neg - 1 else neg ) Nothing -> (sizes, size, neg) f - :: Maybe (Map K Integer) - -> ( -- Just (old size of inner map, old amount) if the total size shrinks by 1, + :: Maybe (Map K Quantity) + -> ( -- Just (old size of inner map, old quantity) if the total size shrinks by 1, -- otherwise Nothing - Maybe (Int, Integer) - , Maybe (Map K Integer) + Maybe (Int, Quantity) + , Maybe (Map K Quantity) ) f = \case Nothing -> (Nothing, Nothing) Just inner -> - let (amt, inner') = Map.updateLookupWithKey (\_ _ -> Nothing) token inner - in ((Map.size inner,) <$> amt, if Map.null inner' then Nothing else Just inner') + let (qty, inner') = Map.updateLookupWithKey (\_ _ -> Nothing) token inner + in ((Map.size inner,) <$> qty, if Map.null inner' then Nothing else Just inner') -- | \(O(\log \max(m, k))\) lookupCoin :: ByteString -> ByteString -> Value -> Integer lookupCoin (UnsafeK -> currency) (UnsafeK -> token) (unpack -> outer) = case Map.lookup currency outer of Nothing -> 0 - Just inner -> Map.findWithDefault 0 token inner + Just inner -> unQuantity $ Map.findWithDefault zeroQuantity token inner {-| \(O(n_{2}\log \max(m_{1}, k_{1}))\), where \(n_{2}\) is the total size of the second `Value`, \(m_{1}\) is the size of the outer map in the first `Value` and \(k_{1}\) is the size of the largest inner map in the first `Value`. -@a@ contains @b@ if for each @(currency, token, amount)@ in @b@, if @amount > 0@, then -@lookup currency token a >= amount@, and if @amount < 0@, then -@lookup currency token a == amount@. +@a@ contains @b@ if for each @(currency, token, quantity)@ in @b@, +@lookup currency token a >= quantity@. + +Both values must not contain negative amounts. -} valueContains :: Value -> Value -> BuiltinResult Bool valueContains v1 v2 | negativeAmounts v1 > 0 = fail "valueContains: first value contains negative amounts" | negativeAmounts v2 > 0 = fail "valueContains: second value contains negative amounts" - | otherwise = BuiltinSuccess . getAll $ Map.foldrWithKey' go mempty (unpack v2) + | otherwise = BuiltinSuccess . getAll $ Map.foldrWithKey go mempty (unpack v2) where - go c inner = (<>) (Map.foldrWithKey' goInner mempty inner) + go :: K -> Map K Quantity -> All -> All + go c inner = (<>) (Map.foldrWithKey goInner mempty inner) where - goInner t a2 = (<>) (All $ lookupCoin (unK c) (unK t) v1 >= a2) + goInner :: K -> Quantity -> All -> All + goInner t a2 = (<>) (All (lookupCoin (unK c) (unK t) v1 >= unQuantity a2)) {-# INLINEABLE valueContains #-} {-| \(O(n_{1}) + O(n_{2})\), where \(n_{1}\) and \(n_{2}\) are the total sizes (i.e., sum of inner map sizes) of the two maps. -} -unionValue :: Value -> Value -> Value +unionValue :: Value -> Value -> BuiltinResult Value unionValue (unpack -> vA) (unpack -> vB) = - pack' $ - M.merge + pack' <$> + M.mergeA M.preserveMissing M.preserveMissing - ( M.zipWithMaybeMatched $ \_ innerA innerB -> - let inner = - M.merge - M.preserveMissing - M.preserveMissing - ( M.zipWithMaybeMatched $ \_ x y -> - let z = x + y in if z == 0 then Nothing else Just z - ) - innerA - innerB - in if Map.null inner - then Nothing - else - Just inner + ( M.zipWithMaybeAMatched \_ innerA innerB -> + fmap (\inner -> if Map.null inner then Nothing else Just inner) $ + M.mergeA + M.preserveMissing + M.preserveMissing + ( M.zipWithMaybeAMatched \_ x y -> + case addQuantity x y of + Just z -> pure if z == zeroQuantity then Nothing else Just z + Nothing -> + fail "unionValue: quantity is out of the signed 128-bit integer bounds" + ) + innerA + innerB ) vA vB @@ -328,8 +423,8 @@ This is the denotation of @ValueData@ in Plutus V1, V2 and V3. valueData :: Value -> Data valueData = Map . fmap (bimap (B . unK) tokensData) . Map.toList . unpack where - tokensData :: Map K Integer -> Data - tokensData = Map . fmap (bimap (B . unK) I) . Map.toList + tokensData :: Map K Quantity -> Data + tokensData = Map . fmap (bimap (B . unK) (I . unQuantity)) . Map.toList {-# INLINEABLE valueData #-} {-| \(O(n \log n)\). Decodes `Data` into `Value`, in the same way as non-builtin @Value@. @@ -338,7 +433,12 @@ This is the denotation of @UnValueData@ in Plutus V1, V2 and V3. unValueData :: Data -> BuiltinResult Value unValueData = fmap pack . \case - Map cs -> fmap (Map.fromListWith (Map.unionWith (+))) (traverse (bitraverse unB unTokens) cs) + Map cs -> do + -- Use unchecked addition during construction + outerMap <- + Map.fromListWith (Map.unionWith unsafeAddQuantity) <$> traverse (bitraverse unB unTokens) cs + -- Validate all quantities are within bounds + validateQuantities outerMap _ -> fail "unValueData: non-Map constructor" where unB :: Data -> BuiltinResult K @@ -346,14 +446,14 @@ unValueData = B b -> maybe (fail $ "unValueData: invalid key: " <> show (B.unpack b)) pure (k b) _ -> fail "unValueData: non-B constructor" - unI :: Data -> BuiltinResult Integer - unI = \case - I i -> pure i + unQ :: Data -> BuiltinResult Quantity + unQ = \case + I i -> pure (UnsafeQuantity i) _ -> fail "unValueData: non-I constructor" - unTokens :: Data -> BuiltinResult (Map K Integer) + unTokens :: Data -> BuiltinResult (Map K Quantity) unTokens = \case - Map ts -> fmap (Map.fromListWith (+)) (traverse (bitraverse unB unI) ts) + Map ts -> fmap (Map.fromListWith unsafeAddQuantity) (traverse (bitraverse unB unQ) ts) _ -> fail "unValueData: non-Map constructor" {-# INLINEABLE unValueData #-} diff --git a/plutus-core/plutus-core/test/TypeSynthesis/Golden/Signatures/DefaultFun/UnionValue.golden.sig b/plutus-core/plutus-core/test/TypeSynthesis/Golden/Signatures/DefaultFun/UnionValue.golden.sig index 11863c996ea..6415517875e 100644 --- a/plutus-core/plutus-core/test/TypeSynthesis/Golden/Signatures/DefaultFun/UnionValue.golden.sig +++ b/plutus-core/plutus-core/test/TypeSynthesis/Golden/Signatures/DefaultFun/UnionValue.golden.sig @@ -1 +1 @@ -Value -> Value -> Value \ No newline at end of file +Value -> Value -> BuiltinResult Value \ No newline at end of file diff --git a/plutus-core/plutus-core/test/Value/Spec.hs b/plutus-core/plutus-core/test/Value/Spec.hs index 3c87c097396..81e0a447e52 100644 --- a/plutus-core/plutus-core/test/Value/Spec.hs +++ b/plutus-core/plutus-core/test/Value/Spec.hs @@ -20,7 +20,7 @@ import Test.Tasty.QuickCheck import PlutusCore.Builtin (BuiltinResult (..)) import PlutusCore.Data (Data (..)) import PlutusCore.Flat qualified as Flat -import PlutusCore.Generators.QuickCheck.Builtin (ValueAmount (..), genShortHex) +import PlutusCore.Generators.QuickCheck.Builtin (arbitraryBuiltin, genShortHex) import PlutusCore.Value (Value) import PlutusCore.Value qualified as V @@ -38,33 +38,53 @@ prop_packPreservesInvariants :: V.NestedMap -> Property prop_packPreservesInvariants = checkInvariants . V.pack -- | Verifies that @insertCoin@ correctly updates the sizes -prop_insertCoinBookkeeping :: Value -> ValueAmount -> Property -prop_insertCoinBookkeeping v (ValueAmount amt) = +prop_insertCoinBookkeeping :: Value -> V.Quantity -> Property +prop_insertCoinBookkeeping v quantity = forAll (genShortHex (V.totalSize v)) $ \currency -> forAll (genShortHex (V.totalSize v)) $ \token -> - let BuiltinSuccess v' = V.insertCoin (V.unK currency) (V.unK token) amt v + let BuiltinSuccess v' = + V.insertCoin (V.unK currency) (V.unK token) (V.unQuantity quantity) v in checkBookkeeping v' -- | Verifies that @insertCoin@ preserves @Value@ invariants -prop_insertCoinPreservesInvariants :: Value -> ValueAmount -> Property -prop_insertCoinPreservesInvariants v (ValueAmount amt) = +prop_insertCoinPreservesInvariants :: Value -> V.Quantity -> Property +prop_insertCoinPreservesInvariants v quantity = forAll (genShortHex (V.totalSize v)) $ \currency -> forAll (genShortHex (V.totalSize v)) $ \token -> - let BuiltinSuccess v' = V.insertCoin (V.unK currency) (V.unK token) amt v + let BuiltinSuccess v' = + V.insertCoin (V.unK currency) (V.unK token) (V.unQuantity quantity) v in checkInvariants v' prop_unionCommutative :: Value -> Value -> Property -prop_unionCommutative v v' = V.unionValue v v' === V.unionValue v' v +prop_unionCommutative v v' = + case (V.unionValue v v', V.unionValue v' v) of + (BuiltinSuccess r1, BuiltinSuccess r2) -> r1 === r2 + (BuiltinFailure{}, BuiltinFailure{}) -> property True + _ -> property False prop_unionAssociative :: Value -> Value -> Value -> Property prop_unionAssociative v1 v2 v3 = - V.unionValue v1 (V.unionValue v2 v3) === V.unionValue (V.unionValue v1 v2) v3 + let succeeded = not . null + extractValue = foldr const (error "extractValue called on BuiltinFailure") + + r23 = V.unionValue v2 v3 + r12 = V.unionValue v1 v2 + in if succeeded r23 && succeeded r12 + then do + let r1 = V.unionValue v1 (extractValue r23) + r2 = V.unionValue (extractValue r12) v3 + if succeeded r1 && succeeded r2 + then extractValue r1 === extractValue r2 + else discard + else discard prop_insertCoinIdempotent :: Value -> Property prop_insertCoinIdempotent v = v === F.foldl' - (\acc (c, t, a) -> let BuiltinSuccess v' = V.insertCoin (V.unK c) (V.unK t) a acc in v') + (\acc (c, t, q) -> + let BuiltinSuccess v' = V.insertCoin (V.unK c) (V.unK t) (V.unQuantity q) acc + in v') v (V.toFlatList v) @@ -72,8 +92,8 @@ prop_insertCoinValidatesCurrency :: Value -> Property prop_insertCoinValidatesCurrency v = forAll gen33Bytes $ \c -> forAll gen32BytesOrFewer $ \t -> - forAll (arbitrary `suchThat` (/= 0)) $ \amt -> - case V.insertCoin c t amt v of + forAll (arbitraryBuiltin `suchThat` (/= 0)) $ \quantity -> + case V.insertCoin c t quantity v of BuiltinFailure{} -> property True _ -> property False @@ -81,17 +101,36 @@ prop_insertCoinValidatesToken :: Value -> Property prop_insertCoinValidatesToken v = forAll gen32BytesOrFewer $ \c -> forAll gen33Bytes $ \t -> - forAll (arbitrary `suchThat` (/= 0)) $ \amt -> - case V.insertCoin c t amt v of + forAll (arbitraryBuiltin `suchThat` (/= 0)) $ \quantity -> + case V.insertCoin c t quantity v of BuiltinFailure{} -> property True _ -> property False -prop_lookupAfterInsertion :: Value -> ValueAmount -> Property -prop_lookupAfterInsertion v (ValueAmount amt) = +prop_insertCoinValidatesQuantityMin :: Value -> Property +prop_insertCoinValidatesQuantityMin v = + forAll gen32BytesOrFewer $ \c -> + forAll gen32BytesOrFewer $ \t -> + forAll genBelowMinQuantity $ \quantity -> + case V.insertCoin c t quantity v of + BuiltinFailure{} -> property True + _ -> property False + +prop_insertCoinValidatesQuantityMax :: Value -> Property +prop_insertCoinValidatesQuantityMax v = + forAll gen32BytesOrFewer $ \c -> + forAll gen32BytesOrFewer $ \t -> + forAll genAboveMaxQuantity $ \quantity -> + case V.insertCoin c t quantity v of + BuiltinFailure{} -> property True + _ -> property False + +prop_lookupAfterInsertion :: Value -> V.Quantity -> Property +prop_lookupAfterInsertion v quantity = forAll (genShortHex (V.totalSize v)) $ \currency -> forAll (genShortHex (V.totalSize v)) $ \token -> - let BuiltinSuccess v' = V.insertCoin (V.unK currency) (V.unK token) amt v - in V.lookupCoin (V.unK currency) (V.unK token) v' === amt + let BuiltinSuccess v' = + V.insertCoin (V.unK currency) (V.unK token) (V.unQuantity quantity) v + in V.lookupCoin (V.unK currency) (V.unK token) v' === V.unQuantity quantity prop_lookupAfterDeletion :: Value -> Property prop_lookupAfterDeletion v = @@ -124,7 +163,8 @@ prop_deleteCoinPreservesInvariants v = vs = scanr (\(c, t, _) -> V.deleteCoin (V.unK c) (V.unK t)) v fl toPositiveValue :: Value -> Value -toPositiveValue = V.pack . fmap (fmap abs) . V.unpack +toPositiveValue = + V.pack . fmap (Map.map (fromMaybe maxBound . V.quantity . abs . V.unQuantity)) . V.unpack prop_containsReflexive :: Value -> Property prop_containsReflexive (toPositiveValue -> v) = @@ -159,12 +199,22 @@ gen32BytesOrFewer = do gen33Bytes :: Gen ByteString gen33Bytes = B.pack <$> vectorOf 33 arbitrary +genBelowMinQuantity :: Gen Integer +genBelowMinQuantity = do + Positive offset <- arbitrary + pure (V.unQuantity minBound - offset) + +genAboveMaxQuantity :: Gen Integer +genAboveMaxQuantity = do + Positive offset <- arbitrary + pure (V.unQuantity maxBound + offset) + prop_flatDecodeSuccess :: Property -prop_flatDecodeSuccess = forAll (arbitrary `suchThat` (/= 0)) $ \amt -> +prop_flatDecodeSuccess = forAll (arbitraryBuiltin `suchThat` (/= 0)) $ \quantity -> forAll gen32BytesOrFewer $ \c -> forAll gen32BytesOrFewer $ \t -> - let flat = Flat.flat $ Map.singleton c (Map.singleton t amt) - BuiltinSuccess v = V.insertCoin c t amt V.empty + let flat = Flat.flat $ Map.singleton c (Map.singleton t quantity) + BuiltinSuccess v = V.insertCoin c t quantity V.empty in Flat.unflat flat === Right v prop_flatDecodeInvalidCurrency :: Property @@ -191,32 +241,105 @@ checkBookkeeping v = actualMaxInnerSize = V.maxInnerSize v expectedSize = sum $ Map.map Map.size (V.unpack v) actualSize = V.totalSize v - expectedNeg = length [amt | inner <- Map.elems (V.unpack v), amt <- Map.elems inner, amt < 0] + expectedNeg = + length [q | inner <- Map.elems (V.unpack v), q <- Map.elems inner, V.unQuantity q < 0] actualNeg = V.negativeAmounts v checkInvariants :: Value -> Property checkInvariants (V.unpack -> v) = property ((not . any Map.null) v) - .&&. property ((not . any (elem 0)) v) + .&&. property ((not . any (elem V.zeroQuantity)) v) -prop_unValueDataValidatesCurrency :: ValueAmount -> Property -prop_unValueDataValidatesCurrency (ValueAmount amt) = +prop_unValueDataValidatesCurrency :: V.Quantity -> Property +prop_unValueDataValidatesCurrency quantity = forAll gen33Bytes $ \c -> forAll gen32BytesOrFewer $ \t -> - let d = Map [(B c, Map [(B t, I amt)])] + let d = Map [(B c, Map [(B t, I (V.unQuantity quantity))])] in case V.unValueData d of BuiltinFailure{} -> property True _ -> property False -prop_unValueDataValidatesToken :: ValueAmount -> Property -prop_unValueDataValidatesToken (ValueAmount amt) = +prop_unValueDataValidatesToken :: V.Quantity -> Property +prop_unValueDataValidatesToken quantity = forAll gen32BytesOrFewer $ \c -> forAll gen33Bytes $ \t -> - let d = Map [(B c, Map [(B t, I amt)])] + let d = Map [(B c, Map [(B t, I (V.unQuantity quantity))])] in case V.unValueData d of BuiltinFailure{} -> property True _ -> property False +prop_unValueDataValidatesQuantityMin :: Property +prop_unValueDataValidatesQuantityMin = + forAll gen32BytesOrFewer $ \c -> + forAll gen32BytesOrFewer $ \t -> + forAll genBelowMinQuantity $ \quantity -> + let d = Map [(B c, Map [(B t, I quantity)])] + in case V.unValueData d of + BuiltinFailure{} -> property True + _ -> property False + +prop_unValueDataValidatesQuantityMax :: Property +prop_unValueDataValidatesQuantityMax = + forAll gen32BytesOrFewer $ \c -> + forAll gen32BytesOrFewer $ \t -> + forAll genAboveMaxQuantity $ \quantity -> + let d = Map [(B c, Map [(B t, I quantity)])] + in case V.unValueData d of + BuiltinFailure{} -> property True + _ -> property False + +prop_unValueDataValidatesMixedQuantities :: Property +prop_unValueDataValidatesMixedQuantities = + forAll genValueDataWithMixedQuantities $ \(dataVal, hasInvalid) -> + case V.unValueData dataVal of + BuiltinSuccess{} -> not hasInvalid + BuiltinSuccessWithLogs{} -> not hasInvalid + BuiltinFailure{} -> hasInvalid + where + -- Generate Value Data with mixed valid/invalid quantities (90% valid, 10% invalid) + genValueDataWithMixedQuantities :: Gen (Data, Bool) + genValueDataWithMixedQuantities = do + numEntries <- chooseInt (1, 10) + entries <- vectorOf numEntries $ do + c <- gen32BytesOrFewer + t <- gen32BytesOrFewer + -- 90% valid, 10% invalid + quantity <- frequency + [ (9, arbitraryBuiltin :: Gen Integer) -- valid range + , (1, oneof [genBelowMinQuantity, genAboveMaxQuantity]) -- invalid + ] + pure (B c, Map [(B t, I quantity)]) + let hasInvalid = any (\(_, Map inner) -> any isInvalidQuantity inner) entries + isInvalidQuantity (_, I q) = q < V.unQuantity minBound || q > V.unQuantity maxBound + isInvalidQuantity _ = False + pure (Map entries, hasInvalid) + +prop_unionValueDetectsOverflow :: Property +prop_unionValueDetectsOverflow = + forAll gen32BytesOrFewer $ \c -> + forAll gen32BytesOrFewer $ \t -> + let BuiltinSuccess v1 = V.insertCoin c t (V.unQuantity maxBound) V.empty + BuiltinSuccess v2 = V.insertCoin c t 1 V.empty + in case V.unionValue v1 v2 of + BuiltinFailure{} -> property True + _ -> property False + +prop_flatDecodeInvalidQuantityMin :: Property +prop_flatDecodeInvalidQuantityMin = + forAll gen32BytesOrFewer $ \c -> + forAll gen32BytesOrFewer $ \t -> + forAll genBelowMinQuantity $ \quantity -> + let flat = Flat.flat $ Map.singleton c (Map.singleton t quantity) + in property . isLeft $ Flat.unflat @Value flat + +prop_flatDecodeInvalidQuantityMax :: Property +prop_flatDecodeInvalidQuantityMax = + forAll gen32BytesOrFewer $ \c -> + forAll gen32BytesOrFewer $ \t -> + forAll genAboveMaxQuantity $ \quantity -> + let flat = Flat.flat $ Map.singleton c (Map.singleton t quantity) + in property . isLeft $ Flat.unflat @Value flat + tests :: TestTree tests = testGroup @@ -251,6 +374,12 @@ tests = , testProperty "insertCoinValidatesToken" prop_insertCoinValidatesToken + , testProperty + "insertCoinValidatesQuantityMin" + prop_insertCoinValidatesQuantityMin + , testProperty + "insertCoinValidatesQuantityMax" + prop_insertCoinValidatesQuantityMax , testProperty "lookupAfterInsertion" prop_lookupAfterInsertion @@ -281,6 +410,18 @@ tests = , testProperty "unValueDataValidatesToken" prop_unValueDataValidatesToken + , testProperty + "unValueDataValidatesQuantityMin" + prop_unValueDataValidatesQuantityMin + , testProperty + "unValueDataValidatesQuantityMax" + prop_unValueDataValidatesQuantityMax + , testProperty + "unValueDataValidatesMixedQuantities" + prop_unValueDataValidatesMixedQuantities + , testProperty + "unionValueDetectsOverflow" + prop_unionValueDetectsOverflow , testProperty "flatRoundtrip" prop_flatRoundtrip @@ -293,4 +434,10 @@ tests = , testProperty "flatDecodeInvalidToken" prop_flatDecodeInvalidToken + , testProperty + "flatDecodeInvalidQuantityMin" + prop_flatDecodeInvalidQuantityMin + , testProperty + "flatDecodeInvalidQuantityMax" + prop_flatDecodeInvalidQuantityMax ] diff --git a/plutus-core/testlib/PlutusCore/Generators/QuickCheck/Builtin.hs b/plutus-core/testlib/PlutusCore/Generators/QuickCheck/Builtin.hs index 0e6c8fc03ad..7a6034e6015 100644 --- a/plutus-core/testlib/PlutusCore/Generators/QuickCheck/Builtin.hs +++ b/plutus-core/testlib/PlutusCore/Generators/QuickCheck/Builtin.hs @@ -286,14 +286,21 @@ uniqueNames wrap ys = do xs <- uniqueVectorOf len $ wrap <$> genShortHex len pure $ zip xs ys +instance ArbitraryBuiltin Value.K where + arbitraryBuiltin = arbitraryBuiltin `suchThatMap` Value.k + instance Arbitrary Value.K where - arbitrary = arbitrary `suchThatMap` Value.k + arbitrary = arbitraryBuiltin + shrink = shrinkBuiltin -newtype ValueAmount = ValueAmount {unValueAmount :: Integer} - deriving newtype (Num, Show) +instance ArbitraryBuiltin Value.Quantity where + arbitraryBuiltin = + chooseInteger (Value.unQuantity minBound, Value.unQuantity maxBound) + `suchThatMap` Value.quantity -instance Arbitrary ValueAmount where - arbitrary = ValueAmount <$> arbitraryBuiltin +instance Arbitrary Value.Quantity where + arbitrary = arbitraryBuiltin + shrink = shrinkBuiltin {-| A wrapper for satisfying an @Arbitrary a@ constraint without implementing an 'Arbitrary' instance for @a@. @@ -311,14 +318,22 @@ instance ArbitraryBuiltin Value where arbitraryBuiltin = do -- Generate values for all of the 'TokenName's in the final 'Value' and split them into a -- list of lists. - amts <- multiSplit0 0.2 . map unValueAmount =<< arbitrary + quantities <- multiSplit0 0.2 =<< arbitraryBuiltin -- Generate 'TokenName's and 'CurrencySymbol's. - currencies <- uniqueNames id =<< traverse (uniqueNames id) amts - pure $ Value.fromList currencies + currencies <- uniqueNames id =<< traverse (uniqueNames id) quantities + case Value.fromList currencies of + BuiltinSuccess v -> pure v + BuiltinSuccessWithLogs _ v -> pure v + BuiltinFailure logs _ -> error $ "Failed to generate valid Value: " <> show logs shrinkBuiltin = - map Value.fromList - . coerce (shrink @[(NoArbitrary Value.K, [(NoArbitrary Value.K, Integer)])]) + mapMaybe + ( \keys -> case Value.fromList keys of + BuiltinSuccess v -> Just v + BuiltinSuccessWithLogs _ v -> Just v + BuiltinFailure{} -> Nothing + ) + . coerce (shrink @[(NoArbitrary Value.K, [(NoArbitrary Value.K, Value.Quantity)])]) . Value.toList instance Arbitrary Value where diff --git a/plutus-ledger-api/testlib/PlutusLedgerApi/Test/V1/Value.hs b/plutus-ledger-api/testlib/PlutusLedgerApi/Test/V1/Value.hs index 1e4186449f1..fe1c354cdcc 100644 --- a/plutus-ledger-api/testlib/PlutusLedgerApi/Test/V1/Value.hs +++ b/plutus-ledger-api/testlib/PlutusLedgerApi/Test/V1/Value.hs @@ -61,4 +61,4 @@ valueFromBuiltin = . Map.toList . PLC.unpack where - inner = fmap (first (TokenName . toBuiltin . PLC.unK)) . Map.toList + inner = fmap (bimap (TokenName . toBuiltin . PLC.unK) PLC.unQuantity) . Map.toList diff --git a/plutus-tx/src/PlutusTx/Builtins/Internal.hs b/plutus-tx/src/PlutusTx/Builtins/Internal.hs index d456be62eb8..6cb42b9923b 100644 --- a/plutus-tx/src/PlutusTx/Builtins/Internal.hs +++ b/plutus-tx/src/PlutusTx/Builtins/Internal.hs @@ -1103,16 +1103,22 @@ lookupCoin (BuiltinByteString c) (BuiltinByteString t) (BuiltinValue v) = {-# OPAQUE lookupCoin #-} unionValue :: BuiltinValue -> BuiltinValue -> BuiltinValue -unionValue (BuiltinValue v1) (BuiltinValue v2) = BuiltinValue $ Value.unionValue v1 v2 +unionValue (BuiltinValue v1) (BuiltinValue v2) = + case Value.unionValue v1 v2 of + BuiltinSuccess v -> BuiltinValue v + BuiltinSuccessWithLogs logs v -> traceAll logs (BuiltinValue v) + BuiltinFailure logs err -> + traceAll (logs <> pure (display err)) $ Haskell.error "unionValue errored." {-# OPAQUE unionValue #-} valueContains :: BuiltinValue -> BuiltinValue -> Bool -valueContains (BuiltinValue v1) (BuiltinValue v2) = case Value.valueContains v1 v2 of - BuiltinSuccess r -> r - BuiltinSuccessWithLogs logs r -> traceAll logs r - BuiltinFailure logs err -> - traceAll (logs <> pure (display err)) $ - Haskell.error "valueContains errored." +valueContains (BuiltinValue v1) (BuiltinValue v2) = + case Value.valueContains v1 v2 of + BuiltinSuccess r -> r + BuiltinSuccessWithLogs logs r -> traceAll logs r + BuiltinFailure logs err -> + traceAll (logs <> pure (display err)) $ + Haskell.error "valueContains errored." {-# OPAQUE valueContains #-} mkValue :: BuiltinValue -> BuiltinData @@ -1120,12 +1126,13 @@ mkValue (BuiltinValue v) = BuiltinData $ Value.valueData v {-# OPAQUE mkValue #-} unsafeDataAsValue :: BuiltinData -> BuiltinValue -unsafeDataAsValue (BuiltinData d) = case Value.unValueData d of - BuiltinSuccess v -> BuiltinValue v - BuiltinSuccessWithLogs logs v -> traceAll logs (BuiltinValue v) - BuiltinFailure logs err -> - traceAll (logs <> pure (display err)) $ - Haskell.error "Data to Value conversion errored." +unsafeDataAsValue (BuiltinData d) = + case Value.unValueData d of + BuiltinSuccess v -> BuiltinValue v + BuiltinSuccessWithLogs logs v -> traceAll logs (BuiltinValue v) + BuiltinFailure logs err -> + traceAll (logs <> pure (display err)) $ + Haskell.error "Data to Value conversion errored." {-# OPAQUE unsafeDataAsValue #-} caseInteger :: Integer -> [a] -> a