Skip to content

Commit b291065

Browse files
authored
No locked funds proofs (IntersectMBO#3929)
* Add discardWallets to drop wallets from the emulator state Useful for testing that wallets can achieve desired outcomes without the help of other wallets. Discarding the wallet state means no transactions can be signed by the discarded wallets. * Add NoLockedFundsProof A no-locked-funds-proof shows (using testing) not only that all funds can be recovered from a contract, but also that each participant can recover "their" funds even if all the other participants refuse to participate. * NoLockedFundsProofs for some example contracts Trivial for Prism, and does not actually hold for Auction since the it's the seller's off-chain code that invokes the Payout transition. * Add some comments * Fix stylish haskell
1 parent f72189b commit b291065

File tree

5 files changed

+217
-38
lines changed

5 files changed

+217
-38
lines changed

plutus-contract/src/Plutus/Contract/Test/ContractModel.hs

Lines changed: 134 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ module Plutus.Contract.Test.ContractModel
106106
, TestStep(..)
107107
, FailedStep(..)
108108
, withDLTest
109+
110+
-- ** Standard properties
111+
--
112+
-- $noLockedFunds
113+
, NoLockedFundsProof(..)
114+
, checkNoLockedFundsProof
109115
) where
110116

111117
import Control.Lens
@@ -121,21 +127,25 @@ import Data.Row (Row)
121127
import Data.Typeable
122128

123129
import Ledger.Slot
124-
import Ledger.Value (Value)
125-
import Plutus.Contract (Contract)
130+
import Ledger.Value (Value, isZero, leq)
131+
import Plutus.Contract (Contract, ContractInstanceId)
126132
import Plutus.Contract.Test
127-
import Plutus.Trace.Emulator as Trace (ContractHandle, ContractInstanceTag, EmulatorTrace,
128-
activateContract, walletInstanceTag)
133+
import Plutus.Trace.Effects.EmulatorControl (discardWallets)
134+
import Plutus.Trace.Emulator as Trace (ContractHandle (..), ContractInstanceTag,
135+
EmulatorTrace, activateContract,
136+
freezeContractInstance, walletInstanceTag)
129137
import PlutusTx.Monoid (inv)
130138
import qualified Test.QuickCheck.DynamicLogic.Monad as DL
131139
import Test.QuickCheck.DynamicLogic.Quantify (Quantifiable (..), Quantification, arbitraryQ, chooseQ,
132140
elementsQ, exactlyQ, frequencyQ, mapQ, oneofQ, whereQ)
133141
import Test.QuickCheck.StateModel hiding (Action, Actions, arbitraryAction, initialState,
134-
monitoring, nextState, perform, precondition, shrinkAction)
142+
monitoring, nextState, perform, precondition, shrinkAction,
143+
stateAfter)
135144
import qualified Test.QuickCheck.StateModel as StateModel
136145

137146
import Test.QuickCheck hiding ((.&&.))
138-
import Test.QuickCheck.Monadic as QC (PropertyM, monadic)
147+
import qualified Test.QuickCheck as QC
148+
import Test.QuickCheck.Monadic (PropertyM, monadic)
139149
import qualified Test.QuickCheck.Monadic as QC
140150

141151
-- | Key-value map where keys and values have three indices that can vary between different elements
@@ -188,7 +198,16 @@ data ContractInstanceSpec state where
188198
-> Contract w schema err () -- ^ The contract that is running in the instance
189199
-> ContractInstanceSpec state
190200

191-
type Handles state = IMap (ContractInstanceKey state) ContractHandle
201+
data WalletContractHandle w s e = WalletContractHandle Wallet (ContractHandle w s e)
202+
203+
type Handles state = IMap (ContractInstanceKey state) WalletContractHandle
204+
205+
-- | Used to freeze other wallets when checking a `NoLockedFundsProof`.
206+
instancesForOtherWallets :: Wallet -> Handles state -> [ContractInstanceId]
207+
instancesForOtherWallets _ IMNil = []
208+
instancesForOtherWallets w (IMCons _ (WalletContractHandle w' h) m)
209+
| w /= w' = chInstanceId h : instancesForOtherWallets w m
210+
| otherwise = instancesForOtherWallets w m
192211

193212

194213
-- | A function returning the `ContractHandle` corresponding to a `ContractInstanceKey`. A
@@ -473,8 +492,8 @@ instance GetModelState (Spec state) where
473492
handle :: (ContractModel s) => Handles s -> HandleFun s
474493
handle handles key =
475494
case imLookup key handles of
476-
Just h -> h
477-
Nothing -> error $ "handle: No handle for " ++ show key
495+
Just (WalletContractHandle _ h) -> h
496+
Nothing -> error $ "handle: No handle for " ++ show key
478497

479498
-- | The `EmulatorTrace` monad does not let you get the result of a computation out, but the way
480499
-- "Test.QuickCheck.Monadic" is set up requires you to provide a function @m Property -> Property@.
@@ -502,13 +521,19 @@ setHandles a = State.modify (<> EmulatorAction (const a))
502521

503522
instance ContractModel state => Show (StateModel.Action (ModelState state) a) where
504523
showsPrec p (ContractAction a) = showsPrec p a
524+
showsPrec p (Unilateral w) = showParen (p >= 11) $ showString "Unilateral " . showsPrec 11 w
505525

506526
deriving instance ContractModel state => Eq (StateModel.Action (ModelState state) a)
507527

508528
instance ContractModel state => StateModel (ModelState state) where
509529

510530
data Action (ModelState state) a where
511531
ContractAction :: Action state -> StateModel.Action (ModelState state) ()
532+
Unilateral :: Wallet -> StateModel.Action (ModelState state) ()
533+
-- ^ This action disables all wallets other than the given wallet, by freezing their
534+
-- contract instances and removing their private keys from the emulator state. This can
535+
-- be used to check that a wallet can *unilaterally* achieve a desired outcome, without
536+
-- the help of other wallets.
512537

513538
type ActionMonad (ModelState state) = ContractMonad state
514539

@@ -517,21 +542,30 @@ instance ContractModel state => StateModel (ModelState state) where
517542
return (Some @() (ContractAction a))
518543

519544
shrinkAction s (ContractAction a) = [ Some @() (ContractAction a') | a' <- shrinkAction s a ]
545+
shrinkAction _ Unilateral{} = []
520546

521547
initialState = ModelState { _currentSlot = 0
522548
, _balanceChanges = Map.empty
523549
, _minted = mempty
524550
, _contractState = initialState }
525551

526552
nextState s (ContractAction cmd) _v = runSpec (nextState cmd) s
553+
nextState s Unilateral{} _ = s
527554

528555
precondition s (ContractAction cmd) = precondition s cmd
556+
precondition _ Unilateral{} = True
529557

530558
perform s (ContractAction cmd) _env = () <$ runEmulator (\ h -> perform (handle h) s cmd)
559+
perform _ (Unilateral w) _env = () <$ runEmulator (\ h -> do
560+
let insts = instancesForOtherWallets w h
561+
mapM_ freezeContractInstance insts
562+
discardWallets (w /=)
563+
)
531564

532565
postcondition _s _cmd _env _res = True
533566

534567
monitoring (s0, s1) (ContractAction cmd) _env _res = monitoring (s0, s1) cmd
568+
monitoring _ Unilateral{} _ _ = id
535569

536570
-- We present a simplified view of test sequences, and DL test cases, so
537571
-- that users do not need to see the variables bound to results.
@@ -661,10 +695,11 @@ toDLTestStep (Witness a) = DL.Witness a
661695

662696
fromDLTest :: forall s. DL.DynLogicTest (ModelState s) -> DLTest s
663697
fromDLTest (DL.BadPrecondition steps acts s) =
664-
BadPrecondition (fromDLTestSteps steps) (map conv acts) (_contractState s)
665-
where conv :: Any (StateModel.Action (ModelState s)) -> FailedStep s
666-
conv (Some (ContractAction act)) = Action act
667-
conv (Error e) = Assert e
698+
BadPrecondition (fromDLTestSteps steps) (concatMap conv acts) (_contractState s)
699+
where conv :: Any (StateModel.Action (ModelState s)) -> [FailedStep s]
700+
conv (Some (ContractAction act)) = [Action act]
701+
conv (Some Unilateral{}) = []
702+
conv (Error e) = [Assert e]
668703
fromDLTest (DL.Looping steps) =
669704
Looping (fromDLTestSteps steps)
670705
fromDLTest (DL.Stuck steps s) =
@@ -673,11 +708,12 @@ fromDLTest (DL.DLScript steps) =
673708
DLScript (fromDLTestSteps steps)
674709

675710
fromDLTestSteps :: [DL.TestStep (ModelState state)] -> [TestStep state]
676-
fromDLTestSteps steps = map fromDLTestStep steps
711+
fromDLTestSteps steps = concatMap fromDLTestStep steps
677712

678-
fromDLTestStep :: DL.TestStep (ModelState state) -> TestStep state
679-
fromDLTestStep (DL.Do (_ := ContractAction act)) = Do act
680-
fromDLTestStep (DL.Witness a) = Witness a
713+
fromDLTestStep :: DL.TestStep (ModelState state) -> [TestStep state]
714+
fromDLTestStep (DL.Do (_ := ContractAction act)) = [Do act]
715+
fromDLTestStep (DL.Do (_ := Unilateral{})) = []
716+
fromDLTestStep (DL.Witness a) = [Witness a]
681717

682718
-- | Run a specific `DLTest`. Typically this test comes from a failed run of `forAllDL`
683719
-- applied to the given `DL` scenario and property. Useful to check if a particular problem has
@@ -890,6 +926,7 @@ forAllDL dl prop = DL.forAllMappedDL toDLTest fromDLTest fromStateModelActions d
890926

891927
instance ContractModel s => DL.DynLogicModel (ModelState s) where
892928
restricted (ContractAction act) = restricted act
929+
restricted Unilateral{} = True
893930

894931
instance GetModelState (DL state) where
895932
type StateType (DL state) = state
@@ -934,7 +971,7 @@ activateWallets [] = return IMNil
934971
activateWallets (ContractInstanceSpec key wallet contract : spec) = do
935972
h <- activateContract wallet contract (instanceTag key wallet)
936973
m <- activateWallets spec
937-
return $ IMCons key h m
974+
return $ IMCons key (WalletContractHandle wallet h) m
938975

939976
-- | Run a `Actions` in the emulator and check that the model and the emulator agree on the final
940977
-- wallet balance changes. Equivalent to
@@ -1001,14 +1038,26 @@ propRunActionsWithOptions ::
10011038
-> Actions state -- ^ The actions to run
10021039
-> Property
10031040
propRunActionsWithOptions opts handleSpecs predicate actions' =
1041+
propRunActionsWithOptions' opts handleSpecs predicate (toStateModelActions actions')
1042+
1043+
propRunActionsWithOptions' ::
1044+
ContractModel state
1045+
=> CheckOptions -- ^ Emulator options
1046+
-> [ContractInstanceSpec state] -- ^ Required wallet contract instances
1047+
-> (ModelState state -> TracePredicate) -- ^ Predicate to check at the end
1048+
-> StateModel.Actions (ModelState state) -- ^ The actions to run
1049+
-> Property
1050+
propRunActionsWithOptions' opts handleSpecs predicate actions =
10041051
monadic (flip State.evalState mempty) $ finalChecks opts finalPredicate $ do
10051052
QC.run $ setHandles $ activateWallets handleSpecs
10061053
void $ runActionsInState StateModel.initialState actions
10071054
where
1008-
finalState = stateAfter actions
1055+
finalState = StateModel.stateAfter actions
10091056
finalPredicate = predicate finalState .&&. checkBalances finalState
10101057
.&&. checkNoCrashes handleSpecs
1011-
actions = toStateModelActions actions'
1058+
1059+
stateAfter :: ContractModel state => Actions state -> ModelState state
1060+
stateAfter actions = StateModel.stateAfter (toStateModelActions actions)
10121061

10131062
checkBalances :: ModelState state -> TracePredicate
10141063
checkBalances s = Map.foldrWithKey (\ w val p -> walletFundsChange w val .&&. p) (pure True) (s ^. balanceChanges)
@@ -1020,3 +1069,68 @@ checkNoCrashes = foldr (\ (ContractInstanceSpec k w c) -> (assertOutcome c (inst
10201069
notError Failed{} = False
10211070
notError Done{} = True
10221071
notError NotDone{} = True
1072+
1073+
-- $noLockedFunds
1074+
-- Showing that funds can not be locked in the contract forever.
1075+
1076+
-- | A "proof" that you can always recover the funds locked by a contract. The first component is
1077+
-- a strategy that from any state of the contract can get all the funds out. The second component
1078+
-- is a strategy for each wallet that from the same state, shows how that wallet can recover the
1079+
-- same (or bigger) amount as using the first strategy, without relying on any actions being taken
1080+
-- by the other wallets.
1081+
--
1082+
-- For instance, in a two player game where each player bets some amount of funds and the winner
1083+
-- gets the pot, there needs to be a mechanism for the players to recover their bid if the other
1084+
-- player simply walks away (perhaps after realising the game is lost). If not, it won't be
1085+
-- possible to construct a `NoLockedFundsProof` that works in a state where both players need to
1086+
-- move before any funds can be collected.
1087+
data NoLockedFundsProof model = NoLockedFundsProof
1088+
{ nlfpMainStrategy :: DL model ()
1089+
-- ^ Strategy to recover all funds from the contract in any reachable state.
1090+
, nlfpWalletStrategy :: Wallet -> DL model ()
1091+
-- ^ A strategy for each wallet to recover as much (or more) funds as the main strategy would
1092+
-- give them in a given state, without the assistance of any other wallet.
1093+
}
1094+
1095+
-- | Check a `NoLockedFundsProof`. Each test will generate an arbitrary sequence of actions
1096+
-- (`anyActions_`) and ask the `nlfpMainStrategy` to recover all funds locked by the contract
1097+
-- after performing those actions. This results in some distribution of the contract funds to the
1098+
-- wallets, and the test then asks each `nlfpWalletStrategy` to show how to recover their
1099+
-- allotment of funds without any assistance from the other wallets (assuming the main strategy
1100+
-- did not execute). When executing wallet strategies, the off-chain instances for other wallets
1101+
-- are killed and their private keys are deleted from the emulator state.
1102+
checkNoLockedFundsProof
1103+
:: (ContractModel model)
1104+
=> CheckOptions
1105+
-> [ContractInstanceSpec model]
1106+
-> NoLockedFundsProof model
1107+
-> Property
1108+
checkNoLockedFundsProof options spec NoLockedFundsProof{nlfpMainStrategy = mainStrat,
1109+
nlfpWalletStrategy = walletStrat } =
1110+
forAllDL anyActions_ $ \ (Actions as) ->
1111+
forAllDL (mainProp as) $ \ as' ->
1112+
let s = stateAfter as'
1113+
as'' = toStateModelActions as' in
1114+
foldl (QC..&&.) (counterexample "Main strategy" $ prop as'') [ walletProp as w bal | (w, bal) <- Map.toList (s ^. balanceChanges) ]
1115+
where
1116+
prop = propRunActionsWithOptions' options spec (\ _ -> pure True)
1117+
1118+
mainProp as = do
1119+
mapM_ action as
1120+
mainStrat
1121+
lockedVal <- askModelState lockedValue
1122+
DL.assert ("Locked funds should be zero, but they are\n " ++ show lockedVal) $ isZero lockedVal
1123+
1124+
walletProp as w bal = counterexample ("Strategy for " ++ show w) $ DL.forAllDL dl prop
1125+
where
1126+
dl = do
1127+
mapM_ action as
1128+
DL.action $ Unilateral w
1129+
walletStrat w
1130+
bal' <- viewModelState (balanceChange w)
1131+
let err = "Unilateral strategy for " ++ show w ++ " should have gotten it at least\n" ++
1132+
" " ++ show bal ++ "\n" ++
1133+
"but it got\n" ++
1134+
" " ++ show bal'
1135+
DL.assert err (bal `leq` bal')
1136+

plutus-contract/src/Plutus/Trace/Effects/EmulatorControl.hs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,19 @@ module Plutus.Trace.Effects.EmulatorControl(
2020
, freezeContractInstance
2121
, thawContractInstance
2222
, chainState
23+
, discardWallets
2324
, handleEmulatorControl
2425
, getSlotConfig
2526
) where
2627

27-
import Control.Lens (view)
28+
import Control.Lens (over, view)
2829
import Control.Monad (void)
2930
import Control.Monad.Freer (Eff, Member, type (~>))
3031
import Control.Monad.Freer.Coroutine (Yield)
3132
import Control.Monad.Freer.Error (Error)
32-
import Control.Monad.Freer.State (State, gets)
33+
import Control.Monad.Freer.State (State, gets, modify)
3334
import Control.Monad.Freer.TH (makeEffect)
35+
import qualified Data.Map as Map
3436
import Ledger.TimeSlot (SlotConfig)
3537
import Plutus.Trace.Emulator.ContractInstance (EmulatorRuntimeError, getThread)
3638
import Plutus.Trace.Emulator.Types (EmulatorMessage (Freeze), EmulatorThreads)
@@ -69,6 +71,7 @@ data EmulatorControl r where
6971
ThawContractInstance :: ContractInstanceId -> EmulatorControl ()
7072
ChainState :: EmulatorControl ChainState
7173
GetSlotConfig :: EmulatorControl SlotConfig
74+
DiscardWallets :: (Wallet -> Bool) -> EmulatorControl () -- ^ Discard wallets matching the predicate.
7275

7376
-- | Interpret the 'EmulatorControl' effect in the 'MultiAgentEffect' and
7477
-- scheduler system calls.
@@ -96,5 +99,6 @@ handleEmulatorControl slotCfg = \case
9699
void $ mkSysCall @effs2 @EmulatorMessage Normal (Right $ Thaw threadId)
97100
ChainState -> gets (view EM.chainState)
98101
GetSlotConfig -> return slotCfg
102+
DiscardWallets discard -> modify @EmulatorState $ over EM.walletStates (Map.filterWithKey (\ k _ -> not $ discard k))
99103

100104
makeEffect ''EmulatorControl

plutus-use-cases/test/Spec/Auction.hs

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ module Spec.Auction
1313
, auctionTrace2
1414
, prop_Auction
1515
, prop_FinishAuction
16+
, prop_NoLockedFunds
1617
) where
1718

1819
import Cardano.Crypto.Hash as Crypto
@@ -220,7 +221,7 @@ instance ContractModel AuctionModel where
220221
Bid w bid -> do
221222
current <- viewContractState currentBid
222223
leader <- viewContractState winner
223-
wait 1
224+
wait 2
224225
when (bid > current && slot <= end) $ do
225226
withdraw w $ Ada.lovelaceValueOf bid
226227
deposit leader $ Ada.lovelaceValueOf current
@@ -238,6 +239,13 @@ instance ContractModel AuctionModel where
238239
perform _ _ Init = delay 3
239240
perform _ _ (WaitUntil slot) = void $ Trace.waitUntilSlot slot
240241
perform handle _ (Bid w bid) = do
242+
-- FIXME: You cannot bid in certain slots when the off-chain code is busy, so to make the
243+
-- tests pass we send two identical bids in consecutive slots. The off-chain code is
244+
-- never busy for more than one slot at a time so at least one of the bids is
245+
-- guaranteed to go through. If both reaches the off-chain code the second will be
246+
-- discarded since it's not higher than the current highest bid.
247+
Trace.callEndpoint @"bid" (handle $ BuyerH w) (Ada.lovelaceOf bid)
248+
delay 1
241249
Trace.callEndpoint @"bid" (handle $ BuyerH w) (Ada.lovelaceOf bid)
242250
delay 1
243251

@@ -253,12 +261,13 @@ delay n = void $ Trace.waitNSlots $ fromIntegral n
253261

254262
prop_Auction :: Actions AuctionModel -> Property
255263
prop_Auction script =
256-
propRunActionsWithOptions (set minLogLevel Info options) spec
264+
propRunActionsWithOptions (set minLogLevel Info options) handleSpec
257265
(\ _ -> pure True) -- TODO: check termination
258266
script
259-
where
260-
spec = ContractInstanceSpec SellerH w1 seller :
261-
[ ContractInstanceSpec (BuyerH w) w (buyer threadToken) | w <- [w2, w3, w4] ]
267+
268+
handleSpec :: [ContractInstanceSpec AuctionModel]
269+
handleSpec = ContractInstanceSpec SellerH w1 seller :
270+
[ ContractInstanceSpec (BuyerH w) w (buyer threadToken) | w <- [w2, w3, w4] ]
262271

263272
finishAuction :: DL AuctionModel ()
264273
finishAuction = do
@@ -271,6 +280,23 @@ finishAuction = do
271280
prop_FinishAuction :: Property
272281
prop_FinishAuction = forAllDL finishAuction prop_Auction
273282

283+
-- | This does not hold! The Payout transition is triggered by the sellers off-chain code, so if the
284+
-- seller walks away the buyer will not get their token (unless going around the off-chain code
285+
-- and building a Payout transaction manually).
286+
noLockProof :: NoLockedFundsProof AuctionModel
287+
noLockProof = NoLockedFundsProof
288+
{ nlfpMainStrategy = strat
289+
, nlfpWalletStrategy = const strat }
290+
where
291+
strat = do
292+
p <- viewContractState phase
293+
when (p == NotStarted) $ action Init
294+
slot <- viewModelState currentSlot
295+
when (slot < 101) $ action $ WaitUntil 101
296+
297+
prop_NoLockedFunds :: Property
298+
prop_NoLockedFunds = checkNoLockedFundsProof options handleSpec noLockProof
299+
274300
tests :: TestTree
275301
tests =
276302
testGroup "auction"
@@ -291,8 +317,6 @@ tests =
291317
.&&. walletFundsChange w2 (inv (Ada.toValue trace2WinningBid) <> theToken)
292318
.&&. walletFundsChange w3 mempty)
293319
auctionTrace2
294-
-- FIXME: Fails because you cannot bid in certain slots when the off-chain code is busy.
295320
, testProperty "QuickCheck property" $
296-
withMaxSuccess 1 $ classify True "FIXME" True
297-
-- withMaxSuccess 10 prop_FinishAuction
321+
withMaxSuccess 10 prop_FinishAuction
298322
]

0 commit comments

Comments
 (0)