diff --git a/plutus-context-builder/CHANGELOG.md b/plutus-context-builder/CHANGELOG.md index 427b45fd..7c121164 100644 --- a/plutus-context-builder/CHANGELOG.md +++ b/plutus-context-builder/CHANGELOG.md @@ -4,6 +4,15 @@ This format is based on [Keep A Changelog](https://keepachangelog.com/en/1.0.0). ## Unreleased +## 2.0.1 -- 2022-05-17 + +### Added + +* module `Test.Plutus.ContextBuilder.Internal`, not recommended for end users, +* the `foldBuilt` utility function, and +* `transactionSpending` and `transactionMinting` for testing of whole + transactions. + ## 2.0 -- 2022-03-09 ### Added diff --git a/plutus-context-builder/plutus-context-builder.cabal b/plutus-context-builder/plutus-context-builder.cabal index 8f5e135e..b13b4f3f 100644 --- a/plutus-context-builder/plutus-context-builder.cabal +++ b/plutus-context-builder/plutus-context-builder.cabal @@ -1,6 +1,6 @@ cabal-version: 3.0 name: plutus-context-builder -version: 2.0 +version: 2.0.1 extra-source-files: CHANGELOG.md common lang @@ -44,9 +44,10 @@ common lang library import: lang - exposed-modules: Test.Plutus.ContextBuilder + exposed-modules: + Test.Plutus.ContextBuilder + , Test.Plutus.ContextBuilder.Internal other-modules: - Test.Plutus.ContextBuilder.Internal Test.Plutus.ContextBuilder.Minting build-depends: diff --git a/plutus-context-builder/src/Test/Plutus/ContextBuilder.hs b/plutus-context-builder/src/Test/Plutus/ContextBuilder.hs index f9290af4..72652dcd 100644 --- a/plutus-context-builder/src/Test/Plutus/ContextBuilder.hs +++ b/plutus-context-builder/src/Test/Plutus/ContextBuilder.hs @@ -25,6 +25,7 @@ module Test.Plutus.ContextBuilder ( ValueType (..), SideUTXO (..), ValidatorUTXO (..), + SomeValidatedUTXO (..), TestUTXO (..), ValidatorUTXOs (..), Minting (..), @@ -42,8 +43,10 @@ module Test.Plutus.ContextBuilder ( -- ** Basic construction input, validatorInput, + validatedInput, output, validatorOutput, + validatedOutput, signedWith, datum, addDatum, @@ -85,12 +88,15 @@ module Test.Plutus.ContextBuilder ( -- ** Direct liftContextFragment, liftNamedContextFragment, + foldBuilt, -- ** Build finished context spendingScriptContext, mintingScriptContext, spendingScriptContextDef, mintingScriptContextDef, + transactionSpending, + transactionMinting, -- ** Utilities defTransactionConfig, @@ -123,8 +129,9 @@ import Test.Plutus.ContextBuilder.Internal ( InputPosition (Head, Tail), Minting (Mint), Naming (Anonymous, Named), - Purpose (ForMinting, ForSpending), + Purpose (ForMinting, ForSpending, ForTransaction), SideUTXO (SideUTXO, sUtxoType, sUtxoValue), + SomeValidatedUTXO (SomeValidatedUTXO, someRedeemer, someSpendingScript, someUTxO), TestUTXO (TestUTXO, tUtxoDatum, tUtxoValue), TransactionConfig ( TransactionConfig, @@ -137,14 +144,17 @@ import Test.Plutus.ContextBuilder.Internal ( ), UTXOType (PubKeyUTXO, ScriptUTXO), ValidatorUTXO (ValidatorUTXO, vUtxoDatum, vUtxoValue), - ValidatorUTXOs (NoValidatorUTXOs, ValidatorUTXOs), + ValidatorUTXOs (MultiValidatorUTXOs, NoValidatorUTXOs, ValidatorUTXOs), ValueType (GeneralValue, TokensValue), defTransactionConfig, + foldBuilt, makeIncompleteContexts, mintingScriptContext, mintingScriptContextDef, spendingScriptContext, spendingScriptContextDef, + transactionMinting, + transactionSpending, ) import Test.Plutus.ContextBuilder.Minting ( MintingPolicyAction (BurnAction, MintAction), @@ -182,7 +192,32 @@ validatorInput :: ValidatorUTXO d -> ContextBuilder ( 'ForSpending d r) 'Anonymous validatorInput name x = - NoNames $ mempty {cfValidatorInputs = ValidatorUTXOs $ Map.singleton name x} + NoNames $ + mempty + { cfValidatorInputs = + ValidatorUTXOs $ Map.singleton name x + } + +{- | Anonymous context from a single 'SomeValidatedUTXO' input. + + = Note + + This input won't be used for spending in any 'ScriptPurpose' of any + 'ScriptContext' built from this. + + @since 2.0.1 +-} +validatedInput :: + -- | Name of the input + Text -> + SomeValidatedUTXO -> + ContextBuilder 'ForTransaction 'Anonymous +validatedInput name x = + NoNames $ + mempty + { cfValidatorInputs = + MultiValidatorUTXOs $ Map.singleton name x + } {- | Anonymous context from a single 'SideUTXO' output. @@ -205,7 +240,26 @@ validatorOutput :: ValidatorUTXO d -> ContextBuilder ( 'ForSpending d r) 'Anonymous validatorOutput name x = - NoNames $ mempty {cfValidatorOutputs = ValidatorUTXOs $ Map.singleton name x} + NoNames $ + mempty + { cfValidatorOutputs = + ValidatorUTXOs $ Map.singleton name x + } + +{- | Anonymous context from a single 'SomeValidatedUTXO' output. + + @since 2.0.1 +-} +validatedOutput :: + Text -> + SomeValidatedUTXO -> + ContextBuilder 'ForTransaction 'Anonymous +validatedOutput name x = + NoNames $ + mempty + { cfValidatorOutputs = + MultiValidatorUTXOs $ Map.singleton name x + } {- | Anonymous context signed with one signature. diff --git a/plutus-context-builder/src/Test/Plutus/ContextBuilder/Internal.hs b/plutus-context-builder/src/Test/Plutus/ContextBuilder/Internal.hs index 215f9b6f..172d80bd 100644 --- a/plutus-context-builder/src/Test/Plutus/ContextBuilder/Internal.hs +++ b/plutus-context-builder/src/Test/Plutus/ContextBuilder/Internal.hs @@ -1,11 +1,22 @@ +{-# LANGUAGE NamedFieldPuns #-} + module Test.Plutus.ContextBuilder.Internal ( -- * Types + TestScript ( + TestValidator, + getTestValidator, + getTestValidatorCode, + TestMintingPolicy, + getTestMintingPolicy, + getTestMintingPolicyCode + ), TransactionConfig (..), InputPosition (..), Purpose (..), UTXOType (..), ValueType (..), SideUTXO (..), + SomeValidatedUTXO (..), ValidatorUTXO (..), ValidatorUTXOs (..), TestUTXO (..), @@ -21,6 +32,9 @@ module Test.Plutus.ContextBuilder.Internal ( spendingScriptContextDef, mintingScriptContextDef, makeIncompleteContexts, + foldBuilt, + transactionSpending, + transactionMinting, ) where import Control.Arrow ((***)) @@ -36,8 +50,10 @@ import Data.Semigroup (sconcat) import Data.Sequence (Seq) import Data.Sequence qualified as Seq import Data.Text (Text) +import Data.Typeable (Typeable) import GHC.Exts (toList) -import Ledger.Scripts (datumHash) +import Ledger (scriptAddress) +import Ledger.Scripts (datumHash, mintingPolicyHash, validatorHash) import Plutus.V1.Ledger.Address (pubKeyHashAddress, scriptHashAddress) import Plutus.V1.Ledger.Api ( BuiltinData, @@ -45,6 +61,7 @@ import Plutus.V1.Ledger.Api ( Datum (Datum), DatumHash, FromData, + MintingPolicy, PubKeyHash, ScriptContext (ScriptContext), ScriptPurpose (Minting, Spending), @@ -76,6 +93,7 @@ import Plutus.V1.Ledger.Api ( txOutValue ), TxOutRef (TxOutRef), + Validator, ValidatorHash, Value, ) @@ -83,6 +101,7 @@ import Plutus.V1.Ledger.Interval (Interval, always) import Plutus.V1.Ledger.Time (POSIXTime) import Plutus.V1.Ledger.Value qualified as Value import Plutus.V1.Ledger.Value.Extra (filterValue) +import PlutusTx (CompiledCode) import PlutusTx.Positive (Positive, getPositive) import Test.Plutus.ContextBuilder.Minting ( MintingPolicyAction (BurnAction, MintAction), @@ -93,6 +112,33 @@ import Test.Plutus.ContextBuilder.Minting ( import PlutusTx.Prelude (length) import Prelude hiding (length) +{- | Typed wrapper for the 'Validator' and 'MintingPolicy' used to match + the datum and redeemer types of the 'Validator' and the data passed to it. + + We don't expose constructors. To create a 'TestScript', use helper functions, + such as 'mkTestValidator' and 'mkTestMintingPolicy'. In case you intend + to test something tricky, you can use 'mkTestValidatorUnsafe' + and 'mkTestMintingPolicyUnsafe' to create a 'TestScript' + that accepts a datum and/or redeemer inconsistent with its internal type. + + @since 2.0.1 +-} +data TestScript (p :: Purpose) where + -- | since 2.0.1 + TestValidator :: + forall (d :: Type) (r :: Type) (code :: Type). + { getTestValidatorCode :: CompiledCode code + , getTestValidator :: Validator + } -> + TestScript ( 'ForSpending d r) + -- | since 2.0.1 + TestMintingPolicy :: + forall (r :: Type) (code :: Type). + { getTestMintingPolicyCode :: CompiledCode code + , getTestMintingPolicy :: MintingPolicy + } -> + TestScript ( 'ForMinting r) + {- Config with the parameters necessary to build the context. @since 1.0 @@ -161,6 +207,10 @@ data Purpose where -- | @since 1.0 redeemer -> Purpose + -- | This tag applies to whole-transaction testing. + -- + -- @since 2.0.1 + ForTransaction :: Purpose {- | Represents metadata of UTxO at different types of address. @@ -221,7 +271,7 @@ data SideUTXO = SideUTXO {- | An UTxO at the tested validator address. This UTxO won't be used as 'Spending' in the 'ScriptPurpose' -of the builded 'ScriptContext'. For the representation of a 'Spending' UTxO +of the built 'ScriptContext'. For the representation of a 'Spending' UTxO use 'TestUTXO'. @since 2.0 @@ -231,12 +281,14 @@ data ValidatorUTXO (datum :: Type) = ValidatorUTXO , vUtxoValue :: Value } deriving stock - ( -- | @since 1.0 + ( -- | @since 2.0.1 + Eq + , -- | @since 1.0 Show ) {- | UTxO at the tested validator address. It will be used as 'Spending' - in the 'ScriptPurpose' of the builded 'ScriptContext'. + in the 'ScriptPurpose' of the built 'ScriptContext'. @since 2.0 -} @@ -263,15 +315,47 @@ data ValidatorUTXOs (p :: Purpose) where (FromData datum, ToData datum, Show datum) => Map.Map Text (ValidatorUTXO datum) -> ValidatorUTXOs ( 'ForSpending datum redeemer) + -- | @since 2.0.1 + MultiValidatorUTXOs :: + Map.Map Text SomeValidatedUTXO -> + ValidatorUTXOs 'ForTransaction -- | @since 1.0 deriving stock instance Show (ValidatorUTXOs p) +{- | An UTxO at a specified validator address. It will be used as 'Spending' + in the 'ScriptPurpose' of the built 'ScriptContext'. + + @since 2.0.1 +-} +data SomeValidatedUTXO where + SomeValidatedUTXO :: + forall (datum :: Type) (redeemer :: Type). + ( FromData datum + , ToData datum + , Show datum + , Typeable datum + , FromData redeemer + , ToData redeemer + , Show redeemer + , Typeable redeemer + ) => + { someUTxO :: ValidatorUTXO datum + , someSpendingScript :: TestScript ( 'ForSpending datum redeemer) + , someRedeemer :: redeemer + } -> + SomeValidatedUTXO + +instance Show SomeValidatedUTXO where + show SomeValidatedUTXO {someUTxO, someRedeemer} = + "SomeValidatedUTXO{someUTxO= " <> shows someUTxO (", someRedeemer= " <> shows someRedeemer "}") + -- | @since 1.0 instance Semigroup (ValidatorUTXOs p) where NoValidatorUTXOs <> x = x x <> NoValidatorUTXOs = x (ValidatorUTXOs m1) <> (ValidatorUTXOs m2) = ValidatorUTXOs $ m1 <> m2 + MultiValidatorUTXOs m1 <> MultiValidatorUTXOs m2 = MultiValidatorUTXOs $ m1 <> m2 -- | @since 1.0 instance Monoid (ValidatorUTXOs p) where @@ -287,12 +371,18 @@ instance Monoid (ValidatorUTXOs p) where @since 1.0 -} -data Minting +newtype Minting = -- | @since 1.0 Mint Value deriving stock ( -- | @since 1.0 - Show + Eq + , Show + ) + deriving newtype + ( -- | @since 2.0.1 + Semigroup + , Monoid ) {- | Indicates whether a 'ContextBuilder' has named components or not. @@ -491,6 +581,96 @@ mintingScriptContext conf cb toks = BurnAction -> negate $ getPositive pos in (tn, i) +transactionSpending :: + (FromData d, ToData d, Show d) => + TestScript ( 'ForSpending d r) -> + ValidatorUTXO d -> + ContextBuilder 'ForTransaction n -> + ContextBuilder ( 'ForSpending d r) n +transactionSpending script input (NoNames cf) = + NoNames (transactionSpendingFragment script input cf) +transactionSpending script input (WithNames cfs) = + WithNames (transactionSpendingFragment script input <$> cfs) + +transactionSpendingFragment :: + forall d r. + (FromData d, ToData d, Show d) => + TestScript ( 'ForSpending d r) -> + ValidatorUTXO d -> + ContextFragment 'ForTransaction -> + ContextFragment ( 'ForSpending d r) +transactionSpendingFragment + spendingScript + utxoToSpend + ContextFragment {cfInputs, cfOutputs, cfSignatures, cfDatums, cfMinting, cfValidatorInputs, cfValidatorOutputs} = + ContextFragment + { cfInputs = cfInputs <> Seq.fromList (Map.elems otherValidatorInputs) + , cfOutputs = cfOutputs <> Seq.fromList (Map.elems otherValidatorOutputs) + , cfSignatures + , cfDatums + , cfMinting + , cfValidatorInputs = ValidatorUTXOs theValidatorInputs + , cfValidatorOutputs = ValidatorUTXOs theValidatorOutputs + } + where + otherValidatorInputs, otherValidatorOutputs :: Map Text SideUTXO + (otherValidatorInputs, theValidatorInputs) = Map.mapEither transactionSpendingUTxO (getUTxOs cfValidatorInputs) + (otherValidatorOutputs, theValidatorOutputs) = Map.mapEither transactionSpendingUTxO (getUTxOs cfValidatorOutputs) + transactionSpendingUTxO :: SomeValidatedUTXO -> Either SideUTXO (ValidatorUTXO d) + transactionSpendingUTxO SomeValidatedUTXO {someUTxO, someSpendingScript} + | encoded someUTxO == encoded utxoToSpend && getTestValidator someSpendingScript == getTestValidator spendingScript = Right utxoToSpend + | otherwise = + Left + SideUTXO + { sUtxoType = ScriptUTXO (validatorHash $ getTestValidator someSpendingScript) $ toBuiltinData $ vUtxoDatum someUTxO + , sUtxoValue = GeneralValue $ vUtxoValue someUTxO + } + encoded :: forall datum. ToData datum => ValidatorUTXO datum -> ValidatorUTXO BuiltinData + encoded (ValidatorUTXO d v) = ValidatorUTXO (toBuiltinData d) v + +transactionMinting :: + TestScript ( 'ForMinting r) -> + ContextBuilder 'ForTransaction n -> + ContextBuilder ( 'ForMinting r) n +transactionMinting script (NoNames cf) = + NoNames (transactionMintingFragment script cf) +transactionMinting script (WithNames cfs) = + WithNames (transactionMintingFragment script <$> cfs) + +transactionMintingFragment :: + TestScript ( 'ForMinting r) -> + ContextFragment 'ForTransaction -> + ContextFragment ( 'ForMinting r) +transactionMintingFragment + mintingPolicy + ContextFragment {cfInputs, cfOutputs, cfSignatures, cfDatums, cfMinting, cfValidatorInputs, cfValidatorOutputs} = + ContextFragment + { cfInputs = cfInputs <> Seq.fromList (Map.elems otherValidatorInputs) + , cfOutputs = cfOutputs <> Seq.fromList (Map.elems otherValidatorOutputs) + , cfSignatures + , cfDatums + , cfMinting = Seq.filter (/= mempty) (otherMint <$> cfMinting) + , cfValidatorInputs = mempty + , cfValidatorOutputs = mempty + } + where + otherMint :: Minting -> Minting + otherMint (Mint val) = Mint (filterValue otherSymbol val) + otherSymbol symbol _ _ = symbol /= Value.mpsSymbol (mintingPolicyHash $ getTestMintingPolicy mintingPolicy) + otherValidatorInputs, otherValidatorOutputs :: Map Text SideUTXO + otherValidatorInputs = transactionUTxO <$> getUTxOs cfValidatorInputs + otherValidatorOutputs = transactionUTxO <$> getUTxOs cfValidatorOutputs + transactionUTxO :: SomeValidatedUTXO -> SideUTXO + transactionUTxO SomeValidatedUTXO {someUTxO, someSpendingScript} = + SideUTXO + { sUtxoType = ScriptUTXO (validatorHash $ getTestValidator someSpendingScript) $ toBuiltinData $ vUtxoDatum someUTxO + , sUtxoValue = GeneralValue $ vUtxoValue someUTxO + } + +getUTxOs :: ValidatorUTXOs 'ForTransaction -> Map.Map Text SomeValidatedUTXO +getUTxOs NoValidatorUTXOs = mempty +getUTxOs (MultiValidatorUTXOs m) = m + {- | Combine a list of partial contexts that should, when combined, validate, but fail when any one partial context is missing. The input is a list @@ -538,9 +718,7 @@ baseTxInfo :: TransactionConfig -> ContextBuilder p n -> TxInfo -baseTxInfo conf = \case - NoNames cf -> go cf - WithNames cfs -> go . fold $ cfs +baseTxInfo conf = go . foldBuilt where go :: ContextFragment p -> TxInfo go cf = @@ -581,6 +759,20 @@ baseTxInfo conf = \case , txInfoId = TxId "testTx" } +{- | Turns an arbitrary 'ContextBuilder' into a 'ContextFragment'. + += Note + + This is a low-level operation designed for maximum control. If possible, use + the other, higher-level, operations in this module instead. + + @since 2.0.1 +-} +foldBuilt :: ContextBuilder p n -> ContextFragment p +foldBuilt = \case + NoNames cf -> cf + WithNames cfs -> fold cfs + checkSideUtxoAddress :: TransactionConfig -> SideUTXO -> Bool checkSideUtxoAddress conf (SideUTXO typ _) = let sideAddress = case typ of @@ -601,6 +793,12 @@ validatorUtxosToDatum = \case NoValidatorUTXOs -> [] ValidatorUTXOs m -> map (\(ValidatorUTXO dt _) -> datumWithHash . toBuiltinData $ dt) $ Map.elems m + MultiValidatorUTXOs m -> + map + ( \SomeValidatedUTXO {someUTxO = ValidatorUTXO dt _} -> + datumWithHash . toBuiltinData $ dt + ) + $ Map.elems m datumWithHash :: BuiltinData -> (DatumHash, Datum) datumWithHash dt = (datumHash dt', dt') @@ -629,6 +827,17 @@ validatorUtxoToTxOut conf (ValidatorUTXO dat val) = , txOutDatumHash = justDatumHash $ toBuiltinData dat } +someValidatedUtxoToTxOut :: + SomeValidatedUTXO -> + TxOut +someValidatedUtxoToTxOut + SomeValidatedUTXO {someUTxO = ValidatorUTXO dat val, someSpendingScript = validator} = + TxOut + { txOutAddress = scriptAddress $ getTestValidator validator + , txOutValue = val + , txOutDatumHash = justDatumHash $ toBuiltinData dat + } + createTxInInfos :: forall (p :: Purpose). TransactionConfig -> @@ -651,6 +860,7 @@ createTxOuts conf sideUtxos valUtxos = valTxOuts = case valUtxos of NoValidatorUTXOs -> [] ValidatorUTXOs m -> fmap (validatorUtxoToTxOut conf) $ Map.elems m + MultiValidatorUTXOs m -> fmap someValidatedUtxoToTxOut $ Map.elems m in valTxOuts <> sideTxOuts createOwnTxInInfo :: TransactionConfig -> BuiltinData -> Value -> TxInInfo diff --git a/tasty-plutus/CHANGELOG.md b/tasty-plutus/CHANGELOG.md index 0612a0c5..a2f9bbd5 100644 --- a/tasty-plutus/CHANGELOG.md +++ b/tasty-plutus/CHANGELOG.md @@ -4,6 +4,17 @@ This format is based on [Keep A Changelog](https://keepachangelog.com/en/1.0.0). ## Unreleased +## 9.1.1 - 2022-05-19 + +### Added + +Whoole transaction test functions, namely: + +* `shouldValidateTransaction`, +* `shouldValidateTransactionTracing`, +* `shouldn'tValidateTransaction`, and +* `shouldn'tValidateTransactionTracing`. + ## 9.1 - 2022-03-22 ### Added diff --git a/tasty-plutus/src/Test/Tasty/Plutus/Instances.hs b/tasty-plutus/src/Test/Tasty/Plutus/Instances.hs new file mode 100644 index 00000000..23931c80 --- /dev/null +++ b/tasty-plutus/src/Test/Tasty/Plutus/Instances.hs @@ -0,0 +1,43 @@ +module Test.Tasty.Plutus.Instances ( + ResultJoin (ResultJoin, getResultJoin), +) where + +import Data.Char qualified as Char +import Test.Tasty.Providers ( + testPassed, + ) +import Test.Tasty.Providers.ConsoleFormat ( + ResultDetailsPrinter (ResultDetailsPrinter), + ) +import Test.Tasty.Runners ( + Outcome (..), + Result (..), + ) + +newtype ResultJoin = ResultJoin {getResultJoin :: Result} + +instance Semigroup ResultJoin where + ResultJoin r1 <> ResultJoin r2 = + ResultJoin $ + Result + { resultOutcome = case (resultOutcome r1, resultOutcome r2) of + (Success, Success) -> Success + (Success, failure) -> failure + (failure, _) -> failure + , resultDescription = resultDescription r1 <+> resultDescription r2 + , resultShortDescription = resultShortDescription r1 <+> resultShortDescription r2 + , resultTime = resultTime r1 + resultTime r2 + , resultDetailsPrinter = ResultDetailsPrinter $ \indent printer -> + let run Result {resultDetailsPrinter = ResultDetailsPrinter r} = r indent printer in run r1 *> run r2 + } + +(<+>) :: String -> String -> String +a <+> b + | all Char.isSpace a = b + | all Char.isSpace b = a + | a == b = a + | '\n' `elem` (a <> b) = a <> "\n" <> b + | otherwise = a <> " " <> b + +instance Monoid ResultJoin where + mempty = ResultJoin (testPassed mempty) diff --git a/tasty-plutus/src/Test/Tasty/Plutus/Internal/TestScript.hs b/tasty-plutus/src/Test/Tasty/Plutus/Internal/TestScript.hs index 1f91128e..944b80fb 100644 --- a/tasty-plutus/src/Test/Tasty/Plutus/Internal/TestScript.hs +++ b/tasty-plutus/src/Test/Tasty/Plutus/Internal/TestScript.hs @@ -2,7 +2,14 @@ module Test.Tasty.Plutus.Internal.TestScript ( -- * Types - TestScript (..), + TestScript ( + TestValidator, + getTestValidator, + getTestValidatorCode, + TestMintingPolicy, + getTestMintingPolicy, + getTestMintingPolicyCode + ), WrappedValidator (..), WrappedMintingPolicy (..), @@ -16,9 +23,7 @@ module Test.Tasty.Plutus.Internal.TestScript ( ) where import Data.Kind (Type) -import Ledger.Typed.Scripts (Validator) import Plutus.V1.Ledger.Api ( - MintingPolicy, mkMintingPolicyScript, mkValidatorScript, ) @@ -28,33 +33,16 @@ import PlutusTx.IsData.Class (FromData (fromBuiltinData)) import PlutusTx.TH (compile) import PlutusTx.Trace (traceError) import Test.Plutus.ContextBuilder (Purpose (ForMinting, ForSpending)) - -{- | Typed wrapper for the 'Validator' and 'MintingPolicy' used to match - the datum and redeemer types of the 'Validator' and the data passed to it. - - We don't expose constructors. To create a 'TestScript', use helper functions, - such as 'mkTestValidator' and 'mkTestMintingPolicy'. In case you intend - to test something tricky, you can use 'mkTestValidatorUnsafe' - and 'mkTestMintingPolicyUnsafe' to create a 'TestScript' - that accepts a datum and/or redeemer inconsistent with its internal type. - - @since 6.0 --} -data TestScript (p :: Purpose) where - -- | since 6.0 - TestValidator :: - forall (d :: Type) (r :: Type) (code :: Type). - { getTestValidatorCode :: CompiledCode code - , getTestValidator :: Validator - } -> - TestScript ( 'ForSpending d r) - -- | since 6.0 - TestMintingPolicy :: - forall (r :: Type) (code :: Type). - { getTestMintingPolicyCode :: CompiledCode code - , getTestMintingPolicy :: MintingPolicy - } -> - TestScript ( 'ForMinting r) +import Test.Plutus.ContextBuilder.Internal ( + TestScript ( + TestMintingPolicy, + TestValidator, + getTestMintingPolicy, + getTestMintingPolicyCode, + getTestValidator, + getTestValidatorCode + ), + ) {- | A wrapper for an untyped 'Validator'. This is similar to 'WrappedValidatorType' from the Plutus 'Ledger.Typed.Scripts' module. diff --git a/tasty-plutus/src/Test/Tasty/Plutus/Script/Unit.hs b/tasty-plutus/src/Test/Tasty/Plutus/Script/Unit.hs index 2bc8beab..58193763 100644 --- a/tasty-plutus/src/Test/Tasty/Plutus/Script/Unit.hs +++ b/tasty-plutus/src/Test/Tasty/Plutus/Script/Unit.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE RankNTypes #-} {-# LANGUAGE Trustworthy #-} {- | @@ -21,6 +23,15 @@ > shouldValidateTracing "Gotta get good messages" tracePred validData validContext > shouldn'tValidateTracing "Oh damn" tracePred invalidData validContext > ... + + = Example with whole-transaction testing + + > validatorTests :: TestTree + > validatorTests = do + > shouldValidateTransaction "Valid transaction" validData mintingPolicies validTransactionContext + > shouldValidateTransaction "Valid inputs" validData mempty validTransactionContext + > shouldn'tValidateTransaction "Invalid transaction" validData mintingPolicies invalidTransactionContext + > ... -} module Test.Tasty.Plutus.Script.Unit ( -- * Testing API @@ -28,12 +39,22 @@ module Test.Tasty.Plutus.Script.Unit ( shouldn'tValidate, shouldValidateTracing, shouldn'tValidateTracing, + + -- ** Whole-transaction testing + shouldValidateTransaction, + shouldn'tValidateTransaction, + shouldValidateTransactionTracing, + shouldn'tValidateTransactionTracing, + SomeMintingPolicy (SomeMintingPolicy), ) where import Control.Arrow ((>>>)) import Control.Monad.Reader (Reader, asks, runReader) import Control.Monad.Writer (tell) import Data.Kind (Type) +import Data.Map.Strict (Map) +import Data.Map.Strict qualified as Map +import Data.Monoid (Ap (Ap, getAp)) import Data.Proxy (Proxy (Proxy)) import Data.Sequence qualified as Seq import Data.Tagged (Tagged (Tagged)) @@ -41,10 +62,13 @@ import Data.Text (Text) import Data.Vector (Vector) import Data.Vector qualified as Vector import Plutus.V1.Ledger.Api ( + CurrencySymbol, ExBudget (ExBudget), ExCPU (ExCPU), ExMemory (ExMemory), ScriptContext, + toBuiltinData, + unsafeFromBuiltinData, ) import Plutus.V1.Ledger.Scripts ( ScriptError ( @@ -53,17 +77,31 @@ import Plutus.V1.Ledger.Scripts ( MalformedScript ), ) +import Plutus.V1.Ledger.Value (TokenName, Value, getValue) +import PlutusTx.AssocMap qualified as AssocMap +import PlutusTx.Positive (Positive) import Test.Plutus.ContextBuilder ( ContextBuilder, + ContextFragment (cfValidatorInputs, cfValidatorOutputs), + MintingPolicyAction (BurnAction, MintAction), + MintingPolicyTask (MPTask), Naming, - Purpose (ForMinting, ForSpending), + Purpose (ForMinting, ForSpending, ForTransaction), + SomeValidatedUTXO (SomeValidatedUTXO, someRedeemer, someSpendingScript, someUTxO), + Tokens (Tokens), TransactionConfig, + ValidatorUTXO (vUtxoDatum, vUtxoValue), + ValidatorUTXOs (MultiValidatorUTXOs, NoValidatorUTXOs), + foldBuilt, + transactionMinting, + transactionSpending, ) import Test.Tasty.Options ( OptionDescription (Option), OptionSet, lookupOption, ) +import Test.Tasty.Plutus.Instances (ResultJoin (ResultJoin, getResultJoin)) import Test.Tasty.Plutus.Internal.Env ( SomeScript (SomeMinter, SomeSpender), getContext, @@ -113,12 +151,14 @@ import Test.Tasty.Plutus.Options ( ) import Test.Tasty.Plutus.TestData ( Outcome (Fail, Pass), + SomeMintingPolicy (SomeMintingPolicy), TestData (MintingTest, SpendingTest), TestItems (ItemsForMinting, ItemsForSpending), ) import Test.Tasty.Providers ( IsTest (run, testOptions), Result, + TestTree, singleTest, testFailed, testPassed, @@ -126,6 +166,9 @@ import Test.Tasty.Providers ( import Text.PrettyPrint (Doc) import Type.Reflection (Typeable) +import PlutusTx.Prelude ((-)) +import Prelude hiding ((-)) + {- | Specify that, given this test data and context, the validation should succeed. @@ -158,6 +201,82 @@ shouldValidateTracing :: WithScript p () shouldValidateTracing name f = addUnitTest Pass (Just f) name +{- | Specify that, given these minting policies and context, the whole + transaction should succeed. Note that, if the transaction mints a currency + whose minting policy is /not/ provided by the @mintingPolicies@ map, it is + assumed to be passing. + + @since 9.1.1 +-} +shouldValidateTransaction :: + forall (n :: Naming). + Typeable n => + String -> + Map CurrencySymbol SomeMintingPolicy -> + ContextBuilder 'ForTransaction n -> + TestTree +shouldValidateTransaction name mintingPolicies cb = + singleTest name (TransactionTester Pass Nothing mintingPolicies cb) + +{- | Specify that, given these minting policies and context, the whole + transaction should /not/ succeed. Note that, if the transaction mints a + currency whose minting policy is /not/ provided by the @mintingPolicies@ + map, the policy is assumed to be passing. + + @since 9.1.1 +-} +shouldn'tValidateTransaction :: + forall (n :: Naming). + Typeable n => + String -> + Map CurrencySymbol SomeMintingPolicy -> + ContextBuilder 'ForTransaction n -> + TestTree +shouldn'tValidateTransaction name mintingPolicies cb = + singleTest name (TransactionTester Fail Nothing mintingPolicies cb) + +{- | Specify that, given these minting policies and context, as well as a + predicate on the entire trace: + + * validation of every consumed input should succeed; + * every policy specified in @mintingPolicies@ whose currency was minted or + burned by the transaction should succeed; and + * the trace that results should satisfy the predicate. + + @since 9.1.1 +-} +shouldValidateTransactionTracing :: + forall (n :: Naming). + Typeable n => + String -> + (Vector Text -> Bool) -> + Map CurrencySymbol SomeMintingPolicy -> + ContextBuilder 'ForTransaction n -> + TestTree +shouldValidateTransactionTracing name f mintingPolicies cb = + singleTest name (TransactionTester Pass (Just f) mintingPolicies cb) + +{- | Specify that, given these minting policies and context, as well as a + predicate on the entire trace, at least one of these criteria fails: + + * validation of an input consumed by the transaction; + * a policy specified in @mintingPolicies@ whose currency was minted or + burned by the transaction; or + * the trace predicate. + + @since 9.1.1 +-} +shouldn'tValidateTransactionTracing :: + forall (n :: Naming). + Typeable n => + String -> + (Vector Text -> Bool) -> + Map CurrencySymbol SomeMintingPolicy -> + ContextBuilder 'ForTransaction n -> + TestTree +shouldn'tValidateTransactionTracing name f mintingPolicies cb = + singleTest name (TransactionTester Fail (Just f) mintingPolicies cb) + {- | Specify that, given this test data and context, the validation should fail. @since 9.0 @@ -225,6 +344,13 @@ data ScriptTest (p :: Purpose) (n :: Naming) where ContextBuilder ( 'ForMinting r) n -> TestScript ( 'ForMinting r) -> ScriptTest ( 'ForMinting r) n + TransactionTester :: + forall (n :: Naming). + Outcome -> + Maybe (Vector Text -> Bool) -> + Map CurrencySymbol SomeMintingPolicy -> + ContextBuilder 'ForTransaction n -> + ScriptTest 'ForTransaction n getOutcome :: forall (p :: Purpose) (n :: Naming). @@ -233,6 +359,7 @@ getOutcome :: getOutcome = \case Spender out _ _ _ _ -> out Minter out _ _ _ _ -> out + TransactionTester out _ _ _ -> out data UnitEnv (p :: Purpose) (n :: Naming) = UnitEnv { envOpts :: OptionSet @@ -259,6 +386,7 @@ getCB = envScriptTest >>> \case Spender _ _ _ cb _ -> cb Minter _ _ _ cb _ -> cb + TransactionTester _ _ _ cb -> cb getTestData :: forall (p :: Purpose) (n :: Naming). @@ -268,6 +396,7 @@ getTestData = envScriptTest >>> \case Spender _ _ td _ _ -> td Minter _ _ td _ _ -> td + TransactionTester {} -> error "There's no test data in TransactionTester" getScript :: forall (p :: Purpose) (n :: Naming). @@ -277,6 +406,7 @@ getScript = envScriptTest >>> \case Spender _ _ _ _ val -> SomeSpender . getTestValidator $ val Minter _ _ _ _ mp -> SomeMinter . getTestMintingPolicy $ mp + TransactionTester {} -> error "There's more than one script in TransactionTester" getMPred :: forall (p :: Purpose) (n :: Naming). @@ -286,15 +416,14 @@ getMPred = envScriptTest >>> \case Spender _ mPred _ _ _ -> mPred Minter _ mPred _ _ _ -> mPred + TransactionTester _ mPred _ _ -> mPred getExpected :: forall (p :: Purpose) (n :: Naming). UnitEnv p n -> Outcome getExpected = - envScriptTest >>> \case - Spender expected _ _ _ _ -> expected - Minter expected _ _ _ _ -> expected + envScriptTest >>> getOutcome getSC :: forall (p :: Purpose) (n :: Naming). @@ -310,6 +439,8 @@ getDumpedState :: getDumpedState = dumpState getConf getCB getTestData instance (Typeable p, Typeable n) => IsTest (ScriptTest p n) where + run opts tt@TransactionTester {} x = + getResultJoin <$> getAp (foldTransactionTests (\t -> Ap $ ResultJoin <$> run opts t x) tt) run opts vt _ = pure $ case lookupOption opts of EstimateOnly -> case getOutcome vt of Fail -> testPassed explainFailureEstimation @@ -332,6 +463,7 @@ instance (Typeable p, Typeable n) => IsTest (ScriptTest p n) where MintingTest red tasks -> let ti = ItemsForMinting red tasks cb out in minterEstimate opts ts ti + TransactionTester {} -> error "Should have been eliminated above" go :: Reader (UnitEnv p n) Result go = case getScriptResult getScript getTestData (getContext getSC) env of Left err -> handleError err @@ -354,6 +486,59 @@ instance (Typeable p, Typeable n) => IsTest (ScriptTest p n) where , Option @PlutusEstimate Proxy ] +foldTransactionTests :: + forall a (n :: Naming). + Monoid a => + (forall (p :: Purpose). Typeable p => ScriptTest p n -> a) -> + ScriptTest 'ForTransaction n -> + a +foldTransactionTests test (TransactionTester outcome mPred mintScripts cb) = + foldMap testSpending validatedInputs + <> foldMap testMinting (AssocMap.toList $ getValue valueDifference) + where + context :: ContextFragment 'ForTransaction + context = foldBuilt cb + valueDifference :: Value + valueDifference = utxosTotalValue (cfValidatorOutputs context) - utxosTotalValue (cfValidatorInputs context) + testMinting :: (CurrencySymbol, AssocMap.Map TokenName Integer) -> a + testMinting (symbol, tokenAmounts) = + case Map.lookup symbol mintScripts of + Nothing -> mempty + Just (SomeMintingPolicy mp redeemer) + | let testPair (name, amount) + | amount < 0 = testPositive BurnAction name (toPositive $ negate amount) + | amount > 0 = testPositive MintAction name (toPositive amount) + | otherwise = mempty + testPositive action name amount = + test $ + Minter + outcome + mPred + (MintingTest redeemer $ pure $ MPTask action $ Tokens name amount) + (transactionMinting mp cb) + mp + toPositive :: Integer -> Positive + toPositive = unsafeFromBuiltinData . toBuiltinData -> + foldMap testPair (AssocMap.toList tokenAmounts) + testSpending :: SomeValidatedUTXO -> a + testSpending SomeValidatedUTXO {someUTxO, someRedeemer, someSpendingScript} = + test $ + Spender + outcome + mPred + (SpendingTest (vUtxoDatum someUTxO) someRedeemer (vUtxoValue someUTxO)) + (transactionSpending someSpendingScript someUTxO cb) + someSpendingScript + validatedInputs :: Map Text SomeValidatedUTXO + validatedInputs = case cfValidatorInputs context of + MultiValidatorUTXOs inputs -> inputs + NoValidatorUTXOs -> mempty + utxosTotalValue :: ValidatorUTXOs 'ForTransaction -> Value + utxosTotalValue NoValidatorUTXOs = mempty + utxosTotalValue (MultiValidatorUTXOs utxos) = foldMap theValue utxos + where + theValue SomeValidatedUTXO {someUTxO = x} = vUtxoValue x + handleError :: forall (p :: Purpose) (n :: Naming). ScriptResult -> diff --git a/tasty-plutus/src/Test/Tasty/Plutus/TestData.hs b/tasty-plutus/src/Test/Tasty/Plutus/TestData.hs index e0f74d9f..24760027 100644 --- a/tasty-plutus/src/Test/Tasty/Plutus/TestData.hs +++ b/tasty-plutus/src/Test/Tasty/Plutus/TestData.hs @@ -15,6 +15,7 @@ module Test.Tasty.Plutus.TestData ( Outcome (..), MintingPolicyAction (..), MintingPolicyTask (..), + SomeMintingPolicy (SomeMintingPolicy), -- * Helper functions passIf, @@ -33,7 +34,7 @@ import Data.Kind (Type) import Data.List.NonEmpty (NonEmpty) import Data.Semigroup (stimes, stimesIdempotent) import Plutus.V1.Ledger.Value (Value) -import PlutusTx +import PlutusTx (FromData, ToData) import Test.Plutus.ContextBuilder ( ContextBuilder, MintingPolicyAction (BurnAction, MintAction), @@ -44,8 +45,10 @@ import Test.Plutus.ContextBuilder ( burnTokens, mintTokens, ) +import Test.Tasty.Plutus.Internal.TestScript (TestScript) import Test.QuickCheck.Arbitrary (Arbitrary (arbitrary, shrink)) import Test.QuickCheck.Gen (Gen) +import Type.Reflection (Typeable) import Prelude {- | All the data needed to test a validator or minting policy. @@ -235,3 +238,19 @@ data TestItems (p :: Purpose) (n :: Naming) where -- | @since 9.0 deriving stock instance Show (TestItems p n) + +{- | A wrapper for a @TestScript 'ForMinting@, meant to be stored in a map and + passed to functions like 'shouldValidateTransaction'. + + @since 9.1.1 +-} +data SomeMintingPolicy where + SomeMintingPolicy :: + (FromData r, ToData r, Show r, Typeable r) => + TestScript ( 'ForMinting r) -> + r -> + SomeMintingPolicy + +-- | @since 9.1.1 +instance Show SomeMintingPolicy where + show = const "SomeMintingPolicy{}" diff --git a/tasty-plutus/tasty-plutus.cabal b/tasty-plutus/tasty-plutus.cabal index a618b026..b6246ef3 100644 --- a/tasty-plutus/tasty-plutus.cabal +++ b/tasty-plutus/tasty-plutus.cabal @@ -54,6 +54,7 @@ library Test.Tasty.Plutus.WithScript other-modules: + Test.Tasty.Plutus.Instances Test.Tasty.Plutus.Internal.DumpScript Test.Tasty.Plutus.Internal.Env Test.Tasty.Plutus.Internal.Estimate diff --git a/tasty-plutus/test/Properties/Validator.hs b/tasty-plutus/test/Properties/Validator.hs index 143c0b5f..a5a50116 100644 --- a/tasty-plutus/test/Properties/Validator.hs +++ b/tasty-plutus/test/Properties/Validator.hs @@ -14,7 +14,10 @@ import Test.Plutus.ContextBuilder ( ContextBuilder, Naming (Anonymous), Purpose (ForSpending), + SomeValidatedUTXO (SomeValidatedUTXO), + ValidatorUTXO (ValidatorUTXO), outToPubKey, + validatedInput, ) import Test.QuickCheck.Plutus.Instances () import Test.Tasty (TestTree, testGroup) @@ -23,6 +26,10 @@ import Test.Tasty.Plutus.Script.Property ( scriptProperty, scriptPropertyPass, ) +import Test.Tasty.Plutus.Script.Unit ( + shouldValidateTransaction, + shouldn'tValidateTransaction, + ) import Test.Tasty.Plutus.TestData ( Generator (GenForSpending), Methodology (Methodology), @@ -77,6 +84,12 @@ tests = "Validator checks secret key" (\(secret, _, _) -> paramTestValidator secret) $ GenForSpending genForParam transformForParam + , shouldValidateTransaction "Unit transaction test" mempty $ + validatedInput "input" $ + SomeValidatedUTXO (ValidatorUTXO (1, 2) mempty) simpleTestValidator (3, 2) + , shouldn'tValidateTransaction "Negative unit transaction test" mempty $ + validatedInput "input" $ + SomeValidatedUTXO (ValidatorUTXO (1, 2) mempty) simpleTestValidator (3, 4) ] genForSimple :: Methodology (Integer, Integer, Integer, Integer, Value)