Skip to content

Commit 2c37ed5

Browse files
feat(testing-interface): Initialize in TestingMonad (#13)
1 parent 152428d commit 2c37ed5

15 files changed

+492
-716
lines changed

src/testing-interface/lib/Convex/TestingInterface.hs

Lines changed: 56 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,6 @@ module Convex.TestingInterface (
3131
defaultOptions,
3232
modifyTransactionLimits,
3333

34-
-- * Actions
35-
Actions (Actions),
36-
InvalidActions (..),
37-
3834
-- * Coverage helpers
3935
withCoverage,
4036
CoverageConfig (..),
@@ -63,7 +59,7 @@ import Control.Monad (foldM, forM, unless, when)
6359
import Control.Monad.IO.Class (MonadIO, liftIO)
6460
import Test.HUnit (Assertion)
6561
import Test.QuickCheck (Arbitrary (..), Gen, Property, counterexample, discard, elements, frequency, oneof, property)
66-
import Test.QuickCheck.Monadic (monadicIO, monitor, run)
62+
import Test.QuickCheck.Monadic (PropertyM, monadicIO, monitor, pick, run)
6763
import Test.Tasty (DependencyType (..), TestTree, sequentialTestGroup, testGroup, withResource)
6864
import Test.Tasty.ExpectedFailure (ignoreTestBecause)
6965
import Test.Tasty.HUnit (assertFailure, testCaseSteps)
@@ -76,7 +72,7 @@ import Control.Lens ((&), (.~), (^.))
7672
import Control.Monad.Trans (MonadTrans (..))
7773
import Convex.Class (MonadBlockchain, MonadMockchain, coverageData, getTxs)
7874
import Convex.CoinSelection (BalanceTxError, coverageFromBalanceTxError)
79-
import Convex.MockChain (MockChainState (..), MockchainT, initialStateFor, runMockchain0IOWith, runMockchainIO)
75+
import Convex.MockChain (MockChainState (..), MockchainT, initialStateFor, runMockchainIO, runMockchainT)
8076
import Convex.MockChain.Defaults qualified as Defaults
8177
import Convex.MonadLog (MonadLog)
8278
import Convex.NodeParams (NodeParams (..))
@@ -116,7 +112,7 @@ The type parameter @state@ represents the model's view of the world. It should
116112
track all relevant information needed to validate that the contract is behaving
117113
correctly.
118114
119-
Minimal complete definition: 'Action', 'initialState', 'arbitraryAction', 'nextState', 'perform'
115+
Minimal complete definition: 'Action', 'initialize', 'arbitraryAction', 'nextState', 'perform'
120116
-}
121117
class (Show state, Eq state) => TestingInterface state where
122118
{- | Actions that can be performed on the contract.
@@ -125,7 +121,7 @@ class (Show state, Eq state) => TestingInterface state where
125121
data Action state
126122

127123
-- | The initial state of the model, before any actions are performed.
128-
initialState :: state
124+
initialize :: (MonadIO m) => TestingMonadT m state
129125

130126
{- | Generate a random action given the current state.
131127
The generated action should be appropriate for the current state.
@@ -205,8 +201,8 @@ class (Show state, Eq state) => TestingInterface state where
205201
Leaving handling of balancing errors to the testing interface is important because
206202
the errors can contain data for code coverage.
207203
-}
208-
newtype TestingMonadT m a = TestingMonad
209-
{ runTestingMonadT :: ExceptT (BalanceTxError C.ConwayEra) (MockchainT C.ConwayEra m) a
204+
newtype TestingMonadT m a = TestingMonadT
205+
{ unTestingMonadT :: ExceptT (BalanceTxError C.ConwayEra) (MockchainT C.ConwayEra m) a
210206
}
211207
deriving newtype
212208
( Functor
@@ -219,12 +215,19 @@ newtype TestingMonadT m a = TestingMonad
219215
, MonadMockchain C.ConwayEra
220216
)
221217

218+
runTestingMonadT
219+
:: NodeParams C.ConwayEra
220+
-> TestingMonadT m a
221+
-> m (Either (BalanceTxError C.ConwayEra) a, MockChainState C.ConwayEra)
222+
runTestingMonadT params (TestingMonadT action) =
223+
runMockchainT (runExceptT action) params (initialStateFor params Wallet.initialUTxOs)
224+
222225
-- Let the TestingMonad fail in IO
223226
instance (MonadIO m) => MonadFail (TestingMonadT m) where
224227
fail s = liftIO $ fail s
225228

226229
instance MonadTrans TestingMonadT where
227-
lift = TestingMonad . lift . lift
230+
lift = TestingMonadT . lift . lift
228231

229232
-- | Opaque wrapper for model state
230233
newtype ModelState state = ModelState {unModelState :: state}
@@ -235,36 +238,6 @@ Key is the threat model name, value is the list of outcomes (one per iteration).
235238
-}
236239
type ThreatModelResults = Map.Map String [ThreatModelOutcome]
237240

238-
-- | A sequence of actions to perform
239-
newtype Actions state = Actions_ [Action state]
240-
241-
newtype InvalidActions state = InvalidActions (Actions state, Action state)
242-
243-
instance (TestingInterface state, Show (Action state)) => Show (InvalidActions state) where
244-
show (InvalidActions (Actions prefix, bad)) =
245-
"InvalidActions " ++ show prefix ++ " then " ++ show bad
246-
247-
pattern Actions :: [Action state] -> Actions state
248-
pattern Actions as = Actions_ as
249-
{-# COMPLETE Actions #-}
250-
251-
instance (TestingInterface state, Show (Action state)) => Show (Actions state) where
252-
show (Actions acts) = "Actions " ++ show acts
253-
254-
instance (TestingInterface state) => Arbitrary (Actions state) where
255-
arbitrary = Actions <$> genActions initialState 10
256-
257-
instance (TestingInterface state) => Arbitrary (InvalidActions state) where
258-
arbitrary = do
259-
-- Generate a valid prefix (builds up state)
260-
prefix <- genActions initialState 10
261-
let finalState = foldl nextState initialState prefix
262-
-- Generate an action that VIOLATES the precondition in that state
263-
maybeInvalid <- arbitraryAction finalState `suchThatMaybe` (not . precondition finalState)
264-
case maybeInvalid of
265-
Nothing -> discard -- tell QuickCheck to skip this case
266-
Just bad -> pure $ InvalidActions (Actions_ prefix, bad)
267-
268241
-- | Try up to 100 times to generate a value satisfying a predicate
269242
suchThatMaybe :: Gen a -> (a -> Bool) -> Gen (Maybe a)
270243
suchThatMaybe gen p = go (100 :: Int)
@@ -359,26 +332,32 @@ negativeTest
359332
:: forall state
360333
. (TestingInterface state, Show (Action state))
361334
=> RunOptions
362-
-> InvalidActions state
363335
-> Property
364-
negativeTest opts (InvalidActions (Actions actions, badAction)) = monadicIO $ do
336+
negativeTest opts = monadicIO $ do
365337
let RunOptions{mcOptions = Options{coverageRef, params}} = opts
366-
initialSt = initialState @state
338+
-- Phase 1: Run the valid prefix, capturing the final mockchain state
339+
(prefixResult, prefixState) <- runTestingMonadT params $ do
340+
initialState <- runInitialization @state opts
367341

368-
when (verbose opts) $
369-
monitor (counterexample $ "Initial state: " ++ show initialSt)
342+
(actions, badAction) <- lift $ pick $ do
343+
-- Generate a valid prefix (builds up state)
344+
prefix <- genActions initialState 10
345+
let finalState = foldl nextState initialState prefix
346+
-- Generate an action that VIOLATES the precondition in that state
347+
maybeInvalid <- arbitraryAction finalState `suchThatMaybe` (not . precondition finalState)
348+
case maybeInvalid of
349+
Nothing -> discard -- tell QuickCheck to skip this case
350+
Just bad -> pure (prefix, bad)
370351

371-
-- Phase 1: Run the valid prefix, capturing the final mockchain state
372-
(prefixResult, prefixState) <- run $ runMockchain0IOWith Wallet.initialUTxOs params $ runExceptT $ runTestingMonadT $ do
373-
foldM (runAction opts) initialSt actions
352+
((,) badAction) <$> foldM (runAction opts) initialState actions
374353

375354
-- Phase 2: Run the bad action starting from the state left by the valid prefix
376355
case prefixResult of
377356
Left err -> do
378357
monitor (counterexample $ "Valid prefix failed: " ++ show err)
379358
pure (property False)
380-
Right finalState -> do
381-
let monadAction = runExceptT $ runTestingMonadT $ perform finalState badAction
359+
Right (badAction, finalState) -> do
360+
let monadAction = runExceptT $ unTestingMonadT $ perform finalState badAction
382361
result' <- run $ try @SomeException $ runMockchainIO monadAction params prefixState
383362
-- We distinguish between validation errors and user errors:
384363
-- if the action failed at the off-chain level (e.g. balancing), we discard the test,
@@ -413,17 +392,14 @@ positiveTest
413392
-- ^ Threat models (early-stop on TMFailed)
414393
-> [ThreatModel ()]
415394
-- ^ Expected vulnerabilities (never early-stop)
416-
-> Actions state
417395
-> Property
418-
positiveTest opts mGetTmResultsRef tms evs (Actions actions) = monadicIO $ do
396+
positiveTest opts mGetTmResultsRef tms evs = monadicIO $ do
419397
let RunOptions{mcOptions = Options{coverageRef, params}} = opts
420-
initialSt = initialState @state
421-
422-
when (verbose opts) $
423-
monitor (counterexample $ "Initial state: " ++ show initialSt)
398+
result <- runTestingMonadT params $ do
399+
initialState <- runInitialization @state opts
400+
actions <- lift $ pick $ genActions initialState 10
424401

425-
result <- run $ runMockchain0IOWith Wallet.initialUTxOs params $ runExceptT $ runTestingMonadT $ do
426-
finalState <- foldM (runAction opts) initialSt actions
402+
finalState <- foldM (runAction opts) initialState actions
427403

428404
-- Run threat models in isolation
429405
-- Note: runThreatModelCheck handles rebalancing failures internally (returns TMSkipped)
@@ -652,6 +628,25 @@ runAction opts modelState action = do
652628

653629
pure modelState'
654630

631+
-- | Initialize the blockchain and validate the model state
632+
runInitialization
633+
:: forall state m
634+
. (TestingInterface state, MonadIO m)
635+
=> RunOptions
636+
-> TestingMonadT (PropertyM m) state
637+
runInitialization opts = do
638+
initialState <- initialize @state
639+
640+
when (verbose opts) $
641+
lift $
642+
monitor (counterexample $ "Initial state: " ++ show initialState)
643+
644+
valid <- validate initialState
645+
unless valid $
646+
fail "Blockchain state does not match model state after initialization"
647+
648+
pure initialState
649+
655650
{- | Configuration for coverage collection and reporting.
656651
657652
Use with 'withCoverage' to set up coverage tracking for your test suite.
@@ -870,7 +865,7 @@ modifyTransactionLimits opts@Options{params = Defaults.pParams -> pp} newVal =
870865
-- | Run the 'TestingMonadT' action with the given options and fail if there is an error
871866
mockchainSucceedsWithOptions :: Options C.ConwayEra -> TestingMonadT IO a -> Assertion
872867
mockchainSucceedsWithOptions Options{params, coverageRef} action =
873-
runMockchain0IOWith Wallet.initialUTxOs params (runExceptT (runTestingMonadT action))
868+
runTestingMonadT params action
874869
>>= \(res, st) -> do
875870
let covData = st ^. coverageData
876871
for_ coverageRef $ \ref -> modifyIORef ref (<> covData)
@@ -885,7 +880,7 @@ mockchainSucceedsWithOptions Options{params, coverageRef} action =
885880
-}
886881
mockchainFailsWithOptions :: Options C.ConwayEra -> TestingMonadT IO a -> (BalanceTxError C.ConwayEra -> Assertion) -> Assertion
887882
mockchainFailsWithOptions Options{params, coverageRef} action handleError =
888-
runMockchain0IOWith Wallet.initialUTxOs params (runExceptT (runTestingMonadT action))
883+
runTestingMonadT params action
889884
>>= \(res, st) -> do
890885
let covData = st ^. coverageData
891886
for_ coverageRef $ \ref -> modifyIORef ref (<> covData)

src/testing-interface/test/AikenBankSpec.hs

Lines changed: 24 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -881,64 +881,52 @@ aikenBank03UnitTests =
881881

882882
-- | Model state for the Bank contract
883883
data BankModel = BankModel
884-
{ bmBankTxIn :: Maybe C.TxIn
884+
{ bmBankTxIn :: C.TxIn
885885
-- ^ Bank UTxO
886886
, bmBankValue :: C.Lovelace
887887
-- ^ Bank's pooled funds
888-
, bmAccountTxIn :: Maybe C.TxIn
888+
, bmAccountTxIn :: C.TxIn
889889
-- ^ Account UTxO
890890
, bmAccountBalance :: Integer
891891
-- ^ Account balance
892-
, bmAccountOwner :: Maybe PlutusTx.BuiltinByteString
892+
, bmAccountOwner :: PlutusTx.BuiltinByteString
893893
-- ^ Account owner
894-
, bmInitialized :: Bool
895894
}
896895
deriving stock (Show, Eq)
897896

898897
instance TestingInterface BankModel where
899898
data Action BankModel
900-
= InitBankAction
901-
| DepositAction C.Lovelace
899+
= DepositAction C.Lovelace
902900
| WithdrawAction C.Lovelace
903901
deriving stock (Show, Eq)
904902

905-
initialState =
906-
BankModel
907-
{ bmBankTxIn = Nothing
908-
, bmBankValue = 0
909-
, bmAccountTxIn = Nothing
910-
, bmAccountBalance = 0
911-
, bmAccountOwner = Nothing
912-
, bmInitialized = False
913-
}
903+
initialize = do
904+
let txBody = execBuildTx $ initBank bankLevel00 Defaults.networkId Wallet.w1 100_000_000
905+
void $ balanceAndSubmit mempty Wallet.w1 txBody TrailingChange []
906+
let ownerBytes = PlutusTx.toBuiltin $ C.serialiseToRawBytes (verificationKeyHash Wallet.w1)
907+
pure $
908+
BankModel
909+
{ bmBankTxIn = C.TxIn dummyTxId (C.TxIx 0)
910+
, bmBankValue = 100_000_000
911+
, bmAccountTxIn = C.TxIn dummyTxId (C.TxIx 1)
912+
, bmAccountBalance = 0
913+
, bmAccountOwner = ownerBytes
914+
}
914915

915916
-- Generate actions following PingPong pattern:
916917
-- - Init actions: TIGHT (only when not initialized) - creates fresh UTxOs
917918
-- - Non-init actions: BROAD (generate invalid variants for negative testing)
918-
arbitraryAction model
919-
| not (bmInitialized model) = pure InitBankAction
920-
| otherwise =
921-
QC.frequency
922-
[ (3, DepositAction <$> (fromInteger <$> QC.choose (1_000_000, 20_000_000)))
923-
, (7, WithdrawAction <$> (fromInteger <$> QC.choose (1_000_000, 20_000_000))) -- May overdraw for negative testing
924-
]
925-
926-
precondition model InitBankAction = not (bmInitialized model)
927-
precondition model (DepositAction _) = bmInitialized model
919+
arbitraryAction _model =
920+
QC.frequency
921+
[ (3, DepositAction <$> (fromInteger <$> QC.choose (1_000_000, 20_000_000)))
922+
, (7, WithdrawAction <$> (fromInteger <$> QC.choose (1_000_000, 20_000_000))) -- May overdraw for negative testing
923+
]
924+
925+
precondition _model (DepositAction _) = True
928926
precondition model (WithdrawAction amt) =
929-
bmInitialized model && bmAccountBalance model >= fromIntegral amt
927+
bmAccountBalance model >= fromIntegral amt
930928

931929
nextState model action = case action of
932-
InitBankAction ->
933-
let ownerBytes = PlutusTx.toBuiltin $ C.serialiseToRawBytes (verificationKeyHash Wallet.w1)
934-
in model
935-
{ bmBankTxIn = Just (C.TxIn dummyTxId (C.TxIx 0))
936-
, bmBankValue = 100_000_000
937-
, bmAccountTxIn = Just (C.TxIn dummyTxId (C.TxIx 1))
938-
, bmAccountBalance = 0
939-
, bmAccountOwner = Just ownerBytes
940-
, bmInitialized = True
941-
}
942930
DepositAction amt ->
943931
model
944932
{ bmBankValue = bmBankValue model + amt
@@ -951,9 +939,6 @@ instance TestingInterface BankModel where
951939
}
952940

953941
perform _model action = case action of
954-
InitBankAction -> do
955-
let txBody = execBuildTx $ initBank bankLevel00 Defaults.networkId Wallet.w1 100_000_000
956-
void $ balanceAndSubmit mempty Wallet.w1 txBody TrailingChange []
957942
DepositAction amt -> do
958943
[(bankTxIn, bankValue)] <- findBankUtxos bankLevel00
959944
[(accountTxIn, accountValue, accountDatum)] <- findAccountUtxos bankLevel00

src/testing-interface/test/AikenHelloWorldSpec.hs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -319,12 +319,13 @@ instance TestingInterface HelloWorldModel where
319319
-- \^ Unlock with correct password "Hello CTF!"
320320
deriving stock (Show, Eq)
321321

322-
initialState =
323-
HelloWorldModel
324-
{ hwLocked = False
325-
, hwValue = 0
326-
, hwTxIn = Nothing
327-
}
322+
initialize =
323+
pure $
324+
HelloWorldModel
325+
{ hwLocked = False
326+
, hwValue = 0
327+
, hwTxIn = Nothing
328+
}
328329

329330
-- Generate actions following PingPong pattern:
330331
-- - LockFunds: TIGHT (only when not locked) - creates fresh UTxO, always succeeds on-chain

0 commit comments

Comments
 (0)