Skip to content

Commit 531f1b8

Browse files
authored
valueContains: enforce no negative amounts in either Value (#7376)
1 parent 045de09 commit 531f1b8

File tree

5 files changed

+129
-53
lines changed

5 files changed

+129
-53
lines changed

plutus-core/plutus-core/src/PlutusCore/Default/Builtins.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2071,7 +2071,7 @@ instance uni ~ DefaultUni => ToBuiltinMeaning uni DefaultFun where
20712071
(runCostingFunTwoArguments . unimplementedCostingFun)
20722072

20732073
toBuiltinMeaning _semvar ValueContains =
2074-
let valueContainsDenotation :: Value -> Value -> Bool
2074+
let valueContainsDenotation :: Value -> Value -> BuiltinResult Bool
20752075
valueContainsDenotation = Value.valueContains
20762076
{-# INLINE valueContainsDenotation #-}
20772077
in makeBuiltinMeaning

plutus-core/plutus-core/src/PlutusCore/Value.hs

Lines changed: 73 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{-# LANGUAGE DeriveAnyClass #-}
22
{-# LANGUAGE FlexibleInstances #-}
33
{-# LANGUAGE LambdaCase #-}
4+
{-# LANGUAGE TupleSections #-}
45
{-# LANGUAGE ViewPatterns #-}
56

67
module PlutusCore.Value (
@@ -9,6 +10,7 @@ module PlutusCore.Value (
910
k,
1011
unK,
1112
maxKeyLen,
13+
negativeAmounts,
1214
NestedMap,
1315
unpack,
1416
pack,
@@ -34,14 +36,13 @@ import Data.Bitraversable
3436
import Data.ByteString (ByteString)
3537
import Data.ByteString qualified as B
3638
import Data.ByteString.Base64 qualified as Base64
37-
import Data.Functor
38-
import Data.Hashable (Hashable)
39+
import Data.Hashable (Hashable (..))
3940
import Data.IntMap.Strict (IntMap)
4041
import Data.IntMap.Strict qualified as IntMap
4142
import Data.Map.Merge.Strict qualified as M
4243
import Data.Map.Strict (Map)
4344
import Data.Map.Strict qualified as Map
44-
import Data.Maybe
45+
import Data.Monoid (All (..))
4546
import Data.Text.Encoding qualified as Text
4647
import GHC.Generics
4748

@@ -103,17 +104,25 @@ data Value
103104
{- ^ Total size, i.e., sum total of inner map sizes. This avoids recomputing
104105
the total size during the costing of operations like `unionValue`.
105106
-}
107+
{-# UNPACK #-} !Int
108+
-- ^ The number of negative amounts it contains.
106109
deriving stock (Eq, Show, Generic)
107-
deriving anyclass (Hashable, NFData)
110+
deriving anyclass (NFData)
111+
112+
instance Hashable Value where
113+
hash = hash . unpack
114+
{-# INLINE hash #-}
115+
hashWithSalt salt = hashWithSalt salt . unpack
116+
{-# INLINE hashWithSalt #-}
108117

109118
instance CBOR.Serialise Value where
110-
encode (Value v _ _) = CBOR.encode v
119+
encode (Value v _ _ _) = CBOR.encode v
111120
{-# INLINE encode #-}
112121
decode = pack <$> CBOR.decode
113122
{-# INLINE decode #-}
114123

115124
instance Flat.Flat Value where
116-
encode (Value v _ _) = Flat.encode v
125+
encode (Value v _ _ _) = Flat.encode v
117126
{-# INLINE encode #-}
118127
decode = pack <$> Flat.decode
119128
{-# INLINE decode #-}
@@ -123,7 +132,7 @@ instance Flat.Flat Value where
123132
The map is guaranteed to not contain empty inner map or zero amount.
124133
-}
125134
unpack :: Value -> NestedMap
126-
unpack (Value v _ _) = v
135+
unpack (Value v _ _ _) = v
127136
{-# INLINE unpack #-}
128137

129138
{-| Pack a map from (currency symbol, token name) to amount into a `Value`.
@@ -136,29 +145,34 @@ pack = pack' . normalize
136145

137146
-- | Like `pack` but does not normalize.
138147
pack' :: NestedMap -> Value
139-
pack' v = Value v sizes size
148+
pack' v = Value v sizes size neg
140149
where
141-
(sizes, size) = Map.foldl' alg (mempty, 0) v
142-
alg (ss, s) inner =
150+
(sizes, size, neg) = Map.foldl' alg (mempty, 0, 0) v
151+
alg (ss, s, n) inner =
143152
( IntMap.alter (maybe (Just 1) (Just . (+ 1))) (Map.size inner) ss
144153
, s + Map.size inner
154+
, n + Map.size (Map.filter (< 0) inner)
145155
)
146156
{-# INLINEABLE pack' #-}
147157

148158
{-| Total size, i.e., the number of distinct `(currency symbol, token name)` pairs
149159
contained in the `Value`.
150160
-}
151161
totalSize :: Value -> Int
152-
totalSize (Value _ _ size) = size
162+
totalSize (Value _ _ size _) = size
153163
{-# INLINE totalSize #-}
154164

155165
-- | Size of the largest inner map.
156166
maxInnerSize :: Value -> Int
157-
maxInnerSize (Value _ sizes _) = maybe 0 fst (IntMap.lookupMax sizes)
167+
maxInnerSize (Value _ sizes _ _) = maybe 0 fst (IntMap.lookupMax sizes)
158168
{-# INLINE maxInnerSize #-}
159169

170+
negativeAmounts :: Value -> Int
171+
negativeAmounts (Value _ _ _ neg) = neg
172+
{-# INLINE negativeAmounts #-}
173+
160174
empty :: Value
161-
empty = Value mempty mempty 0
175+
empty = Value mempty mempty 0 0
162176
{-# INLINE empty #-}
163177

164178
toList :: Value -> [(K, [(K, Integer)])]
@@ -189,52 +203,70 @@ instance Pretty Value where
189203
the size of the largest inner map.
190204
-}
191205
insertCoin :: ByteString -> ByteString -> Integer -> Value -> BuiltinResult Value
192-
insertCoin currency token amt v@(Value outer sizes size)
206+
insertCoin currency token amt v@(Value outer sizes size neg)
193207
| amt == 0 = pure $ deleteCoin currency token v
194208
| otherwise = case (k currency, k token) of
195209
(Nothing, _) -> fail $ "insertCoin: invalid currency: " <> show (B.unpack currency)
196210
(_, Nothing) -> fail $ "insertCoin: invalid token: " <> show (B.unpack token)
197211
(Just ck, Just tk) ->
198212
let f
199213
:: Maybe (Map K Integer)
200-
-> ( -- Just (old size of inner map) if the total size grows by 1,
201-
-- otherwise Nothing
202-
Maybe Int
214+
-> ( -- Left (old size of inner map) if the total size grows by 1,
215+
-- otherwise, Right (old amount)
216+
Either Int Integer
203217
, Maybe (Map K Integer)
204218
)
205219
f = \case
206-
Nothing -> (Just 0, Just (Map.singleton tk amt))
220+
Nothing -> (Left 0, Just (Map.singleton tk amt))
207221
Just inner ->
208-
let (isJust -> exists, inner') =
222+
let (moldAmt, inner') =
209223
Map.insertLookupWithKey (\_ _ _ -> amt) tk amt inner
210-
in (if exists then Nothing else Just (Map.size inner), Just inner')
211-
(mold, outer') = Map.alterF f ck outer
212-
(sizes', size') = case mold of
213-
Just old -> (updateSizes old (old + 1) sizes, size + 1)
214-
Nothing -> (sizes, size)
215-
in pure $ Value outer' sizes' size'
224+
in (maybe (Left (Map.size inner)) Right moldAmt, Just inner')
225+
(res, outer') = Map.alterF f ck outer
226+
(sizes', size', neg') = case res of
227+
Left oldSize ->
228+
( updateSizes oldSize (oldSize + 1) sizes
229+
, size + 1
230+
, if amt < 0 then neg + 1 else neg
231+
)
232+
Right oldAmt ->
233+
( sizes
234+
, size
235+
, if oldAmt < 0 && amt > 0
236+
then neg - 1
237+
else
238+
if oldAmt > 0 && amt < 0
239+
then neg + 1
240+
else neg
241+
)
242+
in pure $ Value outer' sizes' size' neg'
216243
{-# INLINEABLE insertCoin #-}
217244

218245
-- | \(O(\log \max(m, k))\)
219246
deleteCoin :: ByteString -> ByteString -> Value -> Value
220-
deleteCoin (UnsafeK -> currency) (UnsafeK -> token) (Value outer sizes size) =
221-
Value outer' sizes' size'
247+
deleteCoin (UnsafeK -> currency) (UnsafeK -> token) (Value outer sizes size neg) =
248+
Value outer' sizes' size' neg'
222249
where
223250
(mold, outer') = Map.alterF f currency outer
224-
(sizes', size') = case mold of
225-
Just old -> (updateSizes old (old - 1) sizes, size - 1)
226-
Nothing -> (sizes, size)
251+
(sizes', size', neg') = case mold of
252+
Just (oldSize, oldAmt) ->
253+
( updateSizes oldSize (oldSize - 1) sizes
254+
, size - 1
255+
, if oldAmt < 0 then neg - 1 else neg
256+
)
257+
Nothing -> (sizes, size, neg)
227258
f
228259
:: Maybe (Map K Integer)
229-
-> ( -- Just (old size of inner map) if the total size shrinks by 1, otherwise Nothing
230-
Maybe Int
260+
-> ( -- Just (old size of inner map, old amount) if the total size shrinks by 1,
261+
-- otherwise Nothing
262+
Maybe (Int, Integer)
231263
, Maybe (Map K Integer)
232264
)
233265
f = \case
234266
Nothing -> (Nothing, Nothing)
235267
Just inner ->
236268
let (amt, inner') = Map.updateLookupWithKey (\_ _ -> Nothing) token inner
237-
in (amt $> Map.size inner, if Map.null inner' then Nothing else Just inner')
269+
in ((Map.size inner,) <$> amt, if Map.null inner' then Nothing else Just inner')
238270

239271
-- | \(O(\log \max(m, k))\)
240272
lookupCoin :: ByteString -> ByteString -> Value -> Integer
@@ -251,18 +283,16 @@ the size of the largest inner map in the first `Value`.
251283
@lookup currency token a >= amount@, and if @amount < 0@, then
252284
@lookup currency token a == amount@.
253285
-}
254-
valueContains :: Value -> Value -> Bool
255-
valueContains v = Map.foldrWithKey' go True . unpack
286+
valueContains :: Value -> Value -> BuiltinResult Bool
287+
valueContains v1 v2
288+
| negativeAmounts v1 > 0 = fail "valueContains: first value contains negative amounts"
289+
| negativeAmounts v2 > 0 = fail "valueContains: second value contains negative amounts"
290+
| otherwise = BuiltinSuccess . getAll $ Map.foldrWithKey' go mempty (unpack v2)
256291
where
257-
go c inner = (&&) (Map.foldrWithKey' goInner True inner)
292+
go c inner = (<>) (Map.foldrWithKey' goInner mempty inner)
258293
where
259-
goInner t a2 =
260-
(&&)
261-
( let a1 = lookupCoin (unK c) (unK t) v
262-
in if a2 > 0
263-
then a1 >= a2
264-
else a1 == a2
265-
)
294+
goInner t a2 = (<>) (All $ lookupCoin (unK c) (unK t) v1 >= a2)
295+
{-# INLINEABLE valueContains #-}
266296

267297
{-| The precise complexity is complicated, but an upper bound
268298
is \(O(n_{1} \log n_{2}) + O(m)\), where \(n_{1}\) is the total size of the smaller
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Value -> Value -> Bool
1+
Value -> Value -> BuiltinResult Bool

plutus-core/plutus-core/test/Value/Spec.hs

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ prop_packUnpackRoundtrip v = v === V.pack (V.unpack v)
2929

3030
-- | Verifies that @pack@ correctly updates the sizes
3131
prop_packBookkeeping :: V.NestedMap -> Property
32-
prop_packBookkeeping = checkSizes . V.pack
32+
prop_packBookkeeping = checkBookkeeping . V.pack
3333

3434
{-| Verifies that @pack@ preserves @Value@ invariants, i.e.,
3535
no empty inner map or zero amount.
@@ -43,7 +43,7 @@ prop_insertCoinBookkeeping v (ValueAmount amt) =
4343
forAll (genShortHex (V.totalSize v)) $ \currency ->
4444
forAll (genShortHex (V.totalSize v)) $ \token ->
4545
let BuiltinSuccess v' = V.insertCoin (V.unK currency) (V.unK token) amt v
46-
in checkSizes v'
46+
in checkBookkeeping v'
4747

4848
-- | Verifies that @insertCoin@ preserves @Value@ invariants
4949
prop_insertCoinPreservesInvariants :: Value -> ValueAmount -> Property
@@ -109,16 +109,45 @@ prop_deleteCoinIdempotent v0 =
109109
BuiltinSuccess v = if V.totalSize v0 > 0 then pure v0 else V.insertCoin "c" "t" 1 v0
110110
fl = V.toFlatList v
111111

112+
prop_deleteCoinBookkeeping :: Value -> Property
113+
prop_deleteCoinBookkeeping v =
114+
conjoin [property (checkBookkeeping v') | v' <- vs]
115+
where
116+
fl = V.toFlatList v
117+
vs = scanr (\(c, t, _) -> V.deleteCoin (V.unK c) (V.unK t)) v fl
118+
119+
prop_deleteCoinPreservesInvariants :: Value -> Property
120+
prop_deleteCoinPreservesInvariants v =
121+
conjoin [property (checkInvariants v') | v' <- vs]
122+
where
123+
fl = V.toFlatList v
124+
vs = scanr (\(c, t, _) -> V.deleteCoin (V.unK c) (V.unK t)) v fl
125+
126+
toPositiveValue :: Value -> Value
127+
toPositiveValue = V.pack . fmap (fmap abs) . V.unpack
128+
112129
prop_containsReflexive :: Value -> Property
113-
prop_containsReflexive v = property $ V.valueContains v v
130+
prop_containsReflexive (toPositiveValue -> v) =
131+
property $ case V.valueContains v v of
132+
BuiltinSuccess r -> r
133+
_ -> False
114134

115135
prop_containsAfterDeletion :: Value -> Property
116-
prop_containsAfterDeletion v =
117-
conjoin [property (V.valueContains v v') | v' <- vs]
136+
prop_containsAfterDeletion (toPositiveValue -> v) =
137+
conjoin [property (case V.valueContains v v' of BuiltinSuccess r -> r; _ -> False) | v' <- vs]
118138
where
119139
fl = V.toFlatList v
120140
vs = scanr (\(c, t, _) -> V.deleteCoin (V.unK c) (V.unK t)) v fl
121141

142+
prop_containsEnforcesPositivity :: Value -> Property
143+
prop_containsEnforcesPositivity v
144+
| V.negativeAmounts v == 0 = case (V.valueContains v V.empty, V.valueContains V.empty v) of
145+
(BuiltinSuccess{}, BuiltinSuccess{}) -> property True
146+
_ -> property False
147+
| otherwise = case (V.valueContains v V.empty, V.valueContains V.empty v) of
148+
(BuiltinFailure{}, BuiltinFailure{}) -> property True
149+
_ -> property False
150+
122151
prop_flatRoundtrip :: Value -> Property
123152
prop_flatRoundtrip v = Flat.unflat (Flat.flat v) === Right v
124153

@@ -152,15 +181,18 @@ prop_flatDecodeInvalidToken =
152181
let flat = Flat.flat $ Map.singleton c (Map.singleton t (100 :: Integer))
153182
in property . isLeft $ Flat.unflat @Value flat
154183

155-
checkSizes :: Value -> Property
156-
checkSizes v =
184+
checkBookkeeping :: Value -> Property
185+
checkBookkeeping v =
157186
(expectedMaxInnerSize === actualMaxInnerSize)
158187
.&&. (expectedSize === actualSize)
188+
.&&. (expectedNeg === actualNeg)
159189
where
160190
expectedMaxInnerSize = fromMaybe 0 . maximumMay $ Map.map Map.size (V.unpack v)
161191
actualMaxInnerSize = V.maxInnerSize v
162192
expectedSize = sum $ Map.map Map.size (V.unpack v)
163193
actualSize = V.totalSize v
194+
expectedNeg = length [amt | inner <- Map.elems (V.unpack v), amt <- Map.elems inner, amt < 0]
195+
actualNeg = V.negativeAmounts v
164196

165197
checkInvariants :: Value -> Property
166198
checkInvariants (V.unpack -> v) =
@@ -228,12 +260,21 @@ tests =
228260
, testProperty
229261
"deleteCoinIdempotent"
230262
prop_deleteCoinIdempotent
263+
, testProperty
264+
"deleteCoinBookkeeping"
265+
prop_deleteCoinBookkeeping
266+
, testProperty
267+
"deleteCoinPreservesInvariants"
268+
prop_deleteCoinPreservesInvariants
231269
, testProperty
232270
"containsReflexive"
233271
prop_containsReflexive
234272
, testProperty
235273
"containsAfterDeletion"
236274
prop_containsAfterDeletion
275+
, testProperty
276+
"containsEnforcesPositivity"
277+
prop_containsEnforcesPositivity
237278
, testProperty
238279
"unValueDataValidatesCurrency"
239280
prop_unValueDataValidatesCurrency

plutus-tx/src/PlutusTx/Builtins/Internal.hs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1107,7 +1107,12 @@ unionValue (BuiltinValue v1) (BuiltinValue v2) = BuiltinValue $ Value.unionValue
11071107
{-# OPAQUE unionValue #-}
11081108

11091109
valueContains :: BuiltinValue -> BuiltinValue -> Bool
1110-
valueContains (BuiltinValue v1) (BuiltinValue v2) = Value.valueContains v1 v2
1110+
valueContains (BuiltinValue v1) (BuiltinValue v2) = case Value.valueContains v1 v2 of
1111+
BuiltinSuccess r -> r
1112+
BuiltinSuccessWithLogs logs r -> traceAll logs r
1113+
BuiltinFailure logs err ->
1114+
traceAll (logs <> pure (display err)) $
1115+
Haskell.error "valueContains errored."
11111116
{-# OPAQUE valueContains #-}
11121117

11131118
mkValue :: BuiltinValue -> BuiltinData

0 commit comments

Comments
 (0)