diff --git a/flake.nix b/flake.nix index b192741..60d2d4d 100644 --- a/flake.nix +++ b/flake.nix @@ -51,20 +51,26 @@ programs.ormolu.enable = true; # Haskell programs.cabal-fmt.enable = true; programs.shfmt.enable = true; - programs.yamlfmt.enable = true; + programs.yamlfmt.enable = pkgs.system != "x86_64-darwin"; # a treefmt-nix+yamlfmt bug on Intel Macs }; }; - flake.hydraJobs = { - testgen-hs = lib.genAttrs (config.systems ++ ["x86_64-windows"]) ( - targetSystem: inputs.self.internal.${targetSystem}.hydraPackage - ); - required = inputs.nixpkgs.legacyPackages.x86_64-linux.releaseTools.aggregate { - name = "github-required"; - meta.description = "All jobs required to pass CI"; - constituents = - lib.collect lib.isDerivation inputs.self.hydraJobs.testgen-hs; + flake.hydraJobs = let + allJobs = { + testgen-hs = lib.genAttrs (config.systems ++ ["x86_64-windows"]) ( + targetSystem: inputs.self.internal.${targetSystem}.hydraPackage + ); + inherit (inputs.self) checks; + }; + in + allJobs + // { + required = inputs.nixpkgs.legacyPackages.x86_64-linux.releaseTools.aggregate { + name = "github-required"; + meta.description = "All jobs required to pass CI"; + constituents = + lib.collect lib.isDerivation allJobs; + }; }; - }; }); } diff --git a/testgen-hs/CLI.hs b/testgen-hs/CLI.hs index 158f524..f65345f 100644 --- a/testgen-hs/CLI.hs +++ b/testgen-hs/CLI.hs @@ -16,7 +16,7 @@ import qualified Data.Version as V import Options.Applicative as O import qualified Paths_testgen_hs as V -data Command = Generate GenerateOptions | Deserialize ByteString | DeserializeStream deriving (Show) +data Command = Generate GenerateOptions | Deserialize ByteString | DeserializeStream | EvaluateStream deriving (Show) data GenerateOptions = GenerateOptions (Maybe Seed) GenSize NumCases TypeCommand deriving (Show) @@ -30,7 +30,7 @@ data TypeCommand = GHCInteger | DataText | ExampleADT - | Tx'Conway + | Tx'ConwayDummy | ApplyTxErr'Byron | ApplyTxErr'Shelley | ApplyTxErr'Allegra @@ -38,6 +38,7 @@ data TypeCommand | ApplyTxErr'Alonzo | ApplyTxErr'Babbage | ApplyTxErr'Conway + | TxScriptFailure'Conway deriving (Show) parse :: IO Command @@ -90,6 +91,15 @@ commandParser = (progDesc "Deserialize an STDIN stream of multiple lines of base16-encoded CBOR of ‘HardForkApplyTxErr’") ) ) + <> ( command + "evaluate-stream" + ( info + ( pure EvaluateStream + <**> helper + ) + (progDesc "Evaluate an STDIN stream of Txs with Utxos") + ) + ) ) optionsParser :: Parser GenerateOptions @@ -138,7 +148,7 @@ typeCommandParser :: Parser TypeCommand typeCommandParser = subparser ( mempty - <> mkTypeCommand Tx'Conway + <> mkTypeCommand Tx'ConwayDummy <> mkTypeCommand ApplyTxErr'Byron <> mkTypeCommand ApplyTxErr'Shelley <> mkTypeCommand ApplyTxErr'Allegra @@ -146,6 +156,7 @@ typeCommandParser = <> mkTypeCommand ApplyTxErr'Alonzo <> mkTypeCommand ApplyTxErr'Babbage <> mkTypeCommand ApplyTxErr'Conway + <> mkTypeCommand TxScriptFailure'Conway <> mkTypeCommand GHCInteger <> mkTypeCommand DataText <> mkTypeCommand ExampleADT diff --git a/testgen-hs/Encoder.hs b/testgen-hs/Encoder.hs new file mode 100644 index 0000000..afb83a4 --- /dev/null +++ b/testgen-hs/Encoder.hs @@ -0,0 +1,435 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeOperators #-} +{-# LANGUAGE ViewPatterns #-} +{-# OPTIONS_GHC -Wno-orphans #-} + +module Encoder + ( serializeTransactionScriptFailure, + serializeDecoderError, + ogmiosSuccess, + ) +where + +import qualified Cardano.Crypto.Hash.Class as CC +import qualified Cardano.Ledger.Alonzo.Core as Al hiding + ( TranslationError, + ) +import qualified Cardano.Ledger.Alonzo.Plutus.TxInfo as Al +import qualified Cardano.Ledger.Alonzo.Scripts as Al +import Cardano.Ledger.Api + ( AsIx (..), + PlutusPurpose, + ) +import qualified Cardano.Ledger.Api as Ledger +import qualified Cardano.Ledger.Babbage.TxInfo as Ba +import qualified Cardano.Ledger.BaseTypes as LedgerBase +import qualified Cardano.Ledger.Binary.Decoding as Binary +import qualified Cardano.Ledger.Conway.Scripts as C +import qualified Cardano.Ledger.Conway.TxInfo as C +import qualified Cardano.Ledger.Core as Ledger +import qualified Cardano.Ledger.Hashes as Hashes +import Cardano.Ledger.Plutus + ( TxOutSource (..), + ) +import qualified Cardano.Ledger.Plutus.Language as Ledger +import qualified Cardano.Ledger.TxIn as Ledger +import qualified Codec.CBOR.Read as Cbor +import qualified Data.Aeson as A +import Data.Aeson.Encoding (Encoding) +import qualified Data.Aeson.Encoding as J +import qualified Data.ByteString as BS +import qualified Data.ByteString.Base16 as B16 +import qualified Data.ByteString.Short as SBS +import Data.Foldable (toList) +import Data.Int (Int64) +import qualified Data.Map as Map +import Data.SatInt + ( fromSatInt, + ) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.Encoding as TE +import qualified PlutusLedgerApi.Common as P +import Prettyprinter (pretty) + +-------------------------------------------------------------------------------- +-- Generic helpers +-------------------------------------------------------------------------------- + +-- | Encode any Foldable as a JSON array using an element encoder. +serializeFoldable :: + (Foldable f) => + (a -> Encoding) -> + f a -> + Encoding +serializeFoldable enc = + J.list enc . toList + +-------------------------------------------------------------------------------- +-- Hash / TxId / TxIn +-------------------------------------------------------------------------------- + +-- | Serialize a SafeHash as lower-case hex JSON string. +serializeHash :: + Hashes.SafeHash crypto -> + Encoding +serializeHash safeH = + let CC.UnsafeHash short = Hashes.extractHash safeH + bs = SBS.fromShort short + hex = B16.encode bs + in J.text (TE.decodeUtf8 hex) + +serializDataHash :: + Al.DataHash -> + Encoding +serializDataHash = + serializeHash + +serializeTxId :: + Ledger.TxId -> + Encoding +serializeTxId txId = + J.pairs $ + J.pair "id" (serializeHash (Ledger.unTxId txId)) + +serializeTxIn :: + Ledger.TxIn -> + Encoding +serializeTxIn (Ledger.TxIn txId ix) = + J.pairs $ + J.pair "transaction" (serializeTxId txId) + <> J.pair "index" (A.toEncoding ix) + +-------------------------------------------------------------------------------- +-- Script purpose index +-------------------------------------------------------------------------------- + +serializeScriptPurposeIndex :: + forall era. + () => + C.ConwayPlutusPurpose AsIx era -> + Encoding +serializeScriptPurposeIndex = \case + C.ConwaySpending ix -> + mk ix "spend" + C.ConwayMinting ix -> + mk ix "mint" + C.ConwayCertifying (AsIx ix) -> + mk ix "publish" + C.ConwayRewarding ix -> + mk ix "withdraw" + C.ConwayVoting (AsIx ix) -> + mk ix "vote" + C.ConwayProposing (AsIx ix) -> + mk ix "propose" + where + mk ix purpose = + J.pairs $ + J.pair "index" (A.toEncoding ix) + <> J.pair "purpose" (J.text purpose) + +serializeExBudget :: + P.ExBudget -> + Encoding +serializeExBudget budget = + J.pairs $ + J.pair "memory" (J.integer (fromSatInt mem)) + <> J.pair "cpu" (J.integer (fromSatInt cpu)) + where + P.ExMemory mem = P.exBudgetMemory budget + P.ExCPU cpu = P.exBudgetCPU budget + +serializeLanguage :: + Ledger.Language -> + Encoding +serializeLanguage = \case + Ledger.PlutusV1 -> "plutus:v1" + Ledger.PlutusV2 -> "plutus:v2" + Ledger.PlutusV3 -> "plutus:v3" + +-- | Ogmios "budget" object from 'ExUnits'. +serializeExUnits :: Al.ExUnits -> Encoding +serializeExUnits (Al.ExUnits mem cpu) = + J.pairs $ + J.pair "memory" (J.integer (fromIntegral mem)) + <> J.pair "cpu" (J.integer (fromIntegral cpu)) + +txInToText :: + Ledger.TxIn -> + Text +txInToText (Ledger.TxIn txid (LedgerBase.TxIx ix)) = + txIdToText txid <> "#" <> T.pack (show ix) + +txIdToText :: Ledger.TxId -> Text +txIdToText (Ledger.TxId (Ledger.originalBytes -> bytes)) = + TE.decodeUtf8 (B16.encode bytes) + +redeemerPtrToText :: forall era. (Ledger.ConwayEraScript era) => PlutusPurpose AsIx era -> Text +redeemerPtrToText = \case + Ledger.SpendingPurpose (AsIx ix) -> "spend:" <> showT ix + Ledger.CertifyingPurpose (AsIx ix) -> "publish:" <> showT ix + Ledger.MintingPurpose (AsIx ix) -> "mint:" <> showT ix + Ledger.RewardingPurpose (AsIx ix) -> "withdraw:" <> showT ix + Ledger.ProposingPurpose (AsIx ix) -> "propose:" <> showT ix + Ledger.VotingPurpose (AsIx ix) -> "vote:" <> showT ix + _ -> error "unreachable: unknown PlutusPurpose" -- matches _are_ exhaustive, but it’s not provable statically + where + showT = T.pack . show -- Word32 → Text + +-------------------------------------------------------------------------------- +-- Ogmios-style error envelope +-------------------------------------------------------------------------------- + +ogmiosError :: + Int64 -> + Text -> + -- | payload / "data" + Encoding -> + Encoding +ogmiosError code msg payload = + J.pairs $ + J.pair "code" (A.toEncoding code) + <> J.pair "message" (J.text msg) + <> J.pair "data" payload + +-- | Build the JSON-RPC success envelope that Ogmios returns when every +-- redeemer succeeds. +ogmiosSuccess :: + Map.Map + (PlutusPurpose AsIx Ledger.ConwayEra) + (Either (Ledger.TransactionScriptFailure Ledger.ConwayEra) Al.ExUnits) -> + J.Encoding +ogmiosSuccess report = + J.list + ( \(ptr, res) -> + case res of + Right exu -> + J.pairs $ + J.pair "validator" (J.text $ redeemerPtrToText ptr) + <> J.pair "budget" (serializeExUnits exu) + Left err -> + J.pairs $ + J.pair "validator" (J.text $ redeemerPtrToText ptr) + <> J.pair + "error" + ( J.pairs $ + J.pair "message" (serializeTransactionScriptFailure err) + ) + ) + (Map.toList report) + +-- makeScriptExecFailure +-- :: Encoding +-- -> Encoding +-- makeScriptExecFailure = ogmiosError +-- 3010 +-- "Some scripts of the transactions terminated with error(s)." + +-------------------------------------------------------------------------------- +-- ContextError +-------------------------------------------------------------------------------- + +serializeContextError :: + ( PlutusPurpose AsIx era ~ C.ConwayPlutusPurpose AsIx era + ) => + C.ConwayContextError era -> + Encoding +serializeContextError err = J.text $ case err of + C.CertificateNotSupported {} -> + "A certificate in the transaction isn't supported in neither plutus:v1 nor plutus:v2. Use plutus:v3 or higher." + C.PlutusPurposeNotSupported {} -> + "A script purpose in the transaction isn't supported in neither plutus:v1 nor plutus:v2. Use plutus:v3 or higher." + C.CurrentTreasuryFieldNotSupported {} -> + "Unsupported field in transaction: 'treasury'. Use plutus:v3 or higher, or remove this field." + C.VotingProceduresFieldNotSupported {} -> + "Unsupported field in transaction: 'votes'. Use plutus:v3 or higher, or remove this field." + C.ProposalProceduresFieldNotSupported {} -> + "Unsupported field in transaction: 'proposals'. Use plutus:v3 or higher, or remove this field." + C.TreasuryDonationFieldNotSupported {} -> + "Unsupported field in transaction: 'donation'. Use plutus:v3 or higher, or remove this field." + C.BabbageContextError (Ba.ByronTxOutInContext TxOutFromInput {}) -> + "Found inputs locked by a (legacy) Byron/Bootstrap address. Don't use those." + C.BabbageContextError (Ba.ByronTxOutInContext TxOutFromOutput {}) -> + "Found outputs to a (legacy) Byron/Bootstrap address. Don't use those." + C.BabbageContextError (Ba.InlineDatumsNotSupported {}) -> + "Inline datums not supported in plutus:v1. Use plutus:v2 or higher." + C.BabbageContextError (Ba.ReferenceScriptsNotSupported {}) -> + "Reference scripts not supported in plutus:v1. Use plutus:v2 or higher." + C.BabbageContextError (Ba.ReferenceInputsNotSupported {}) -> + "Reference inputs not supported in plutus:v1. Use plutus:v2 or higher." + C.BabbageContextError (Ba.RedeemerPointerPointsToNothing purpose) -> + let (title, ptr) = + case purpose of + C.ConwaySpending (AsIx ix) -> ("spending input", ix) + C.ConwayMinting (AsIx ix) -> ("minting policy", ix) + C.ConwayCertifying (AsIx ix) -> ("publishing certificate", ix) + C.ConwayRewarding (AsIx ix) -> ("withdrawing from account", ix) + C.ConwayVoting (AsIx ix) -> ("voting as voter", ix) + C.ConwayProposing (AsIx ix) -> ("proposing governance proposal", ix) + in "Couldn't find corresponding redeemer for " <> title <> " #" <> T.pack (show ptr) <> ". Verify your transaction's construction." + C.BabbageContextError (Ba.AlonzoContextError (Al.TimeTranslationPastHorizon e)) -> + "Uncomputable slot arithmetic; transaction's validity bounds go beyond the foreseeable end of the current era: " <> e + C.BabbageContextError (Ba.AlonzoContextError (Al.TranslationLogicMissingInput i)) -> + "Unknown transaction input (missing from UTxO set): " <> txInToText i + +-------------------------------------------------------------------------------- +-- TransactionScriptFailure +-------------------------------------------------------------------------------- + +serializeTransactionScriptFailure :: + Ledger.TransactionScriptFailure Ledger.ConwayEra -> + Encoding +serializeTransactionScriptFailure = \case + Ledger.RedeemerPointsToUnknownScriptHash reedemers -> + ogmiosError + 3110 + "Extraneous (non-required) redeemers found in the transaction." + ( J.pairs $ + J.pair + "extraneousRedeemers" + (serializeFoldable serializeScriptPurposeIndex [reedemers]) + ) + Ledger.MissingScript purpose _ -> + ogmiosError + 3102 + "An associated script witness is missing." + ( J.pairs $ + J.pair + "missingScripts" + (serializeFoldable serializeScriptPurposeIndex [purpose]) + ) + Ledger.MissingDatum datum -> + ogmiosError + 3111 + "Transaction failed because some Plutus scripts are missing their associated datums." + ( J.pairs $ + J.pair + "missingDatums" + (serializeFoldable serializDataHash [datum]) + ) + Ledger.ValidationFailure _exUnits evalErr traces _ctx -> + ogmiosError + 3012 + "Some of the scripts failed to evaluate to a positive outcome." + ( J.pairs $ + J.pair + "validationError" + (J.text (T.pack (show (pretty evalErr)))) + <> J.pair + "traces" + (serializeFoldable A.toEncoding traces) + ) + Ledger.UnknownTxIn txIn -> + ogmiosError + 3117 + "The transaction contains unknown UTxO references as inputs." + ( J.pairs $ + J.pair "unknownOutputReferences" (serializeFoldable serializeTxIn [txIn]) + ) + Ledger.InvalidTxIn txIn -> + ogmiosError + 3013 + "A redeemer points to an input that isn't locked by a Plutus script." + ( J.pairs $ + J.pair "unsuitableOutputReference" (serializeTxIn txIn) + ) + Ledger.IncompatibleBudget budget -> + ogmiosError + 3161 + "The transaction ran out of execution budget!" + ( J.pairs $ + J.pair "budgetUsed" (serializeExBudget budget) + ) + Ledger.NoCostModelInLedgerState lang -> + ogmiosError + 3115 + "It seems like the transaction is using a Plutus version for which there's no available cost model yet." + ( J.pairs $ + J.pair "missingCostModels" (serializeFoldable serializeLanguage [lang]) + ) + Ledger.ContextError ctxErr -> + ogmiosError + 3004 + "Unable to create the evaluation context from the given transaction." + ( J.pairs $ + J.pair "reason" (serializeContextError ctxErr) + ) + +-- | Human-readable CBOR decode errors. Kept for Ogmios compatibility & future-use even when unused. +serializeDecoderError :: Int -> Binary.DecoderError -> Text +serializeDecoderError size = + extractId . reduceNoise . \case + Binary.DecoderErrorCanonicityViolation lbl -> + "couldn't decode due to internal constraint violations on '" + <> lbl + <> "': \ + \ found CBOR that isn't canonical when I expected it to be." + Binary.DecoderErrorCustom lbl hint -> + "couldn't decode due to internal constraint violations on '" <> lbl <> "': " <> hint + Binary.DecoderErrorDeserialiseFailure lbl (Cbor.DeserialiseFailure offset hint) + | offset >= fromIntegral size -> + "invalid or incomplete value of type '" <> lbl <> "': " <> T.pack (show hint) + Binary.DecoderErrorDeserialiseFailure lbl (Cbor.DeserialiseFailure offset hint) -> + "invalid CBOR found at offset [" + <> T.pack (show offset) + <> "] while decoding a value of type '" + <> lbl + <> "': " + <> T.pack (show hint) + Binary.DecoderErrorEmptyList {} -> + "couldn't decode due to internal constraint violations on a non-empty list: \ + \must not be empty" + Binary.DecoderErrorLeftover lbl bytes -> + "unexpected " + <> T.pack (show (BS.length bytes)) + <> " bytes found left after \ + \successfully deserialising a/an '" + <> lbl + <> "'" + Binary.DecoderErrorSizeMismatch lbl expected actual + | expected >= actual -> + T.pack (show (expected - actual)) + <> " missing element(s) in a \ + \data-structure of type '" + <> lbl + <> "'" + Binary.DecoderErrorSizeMismatch lbl expected actual -> + T.pack (show (actual - expected)) + <> " extra element(s) in a \ + \data-structure of type '" + <> lbl + <> "'" + Binary.DecoderErrorUnknownTag lbl tag -> + "unknown binary tag (" + <> T.pack (show tag) + <> ") when decoding a value of type '" + <> lbl + <> "'\ + \; which is probably because I am trying to decode something else than what \ + \I encountered." + Binary.DecoderErrorVoid -> + "impossible: attempted to decode void. Please open an issue." + where + extractId = id + reduceNoise = + T.replace "\n" " " + . T.replace "Error: " "" + . T.replace "Record" "Object / Array" + . T.replace "Record RecD" "Object / Array" + . T.replace " ShelleyEra" "" + . T.replace " AllegraEra" "" + . T.replace " MaryEra" "" + . T.replace " AlonzoEra" "" + . T.replace " BabbageEra" "" + . T.replace " ConwayEra" "" + . T.replace "value of type ConwayTxBodyRaw" "transaction body" + . T.replace "value of type BabbageTxBodyRaw" "transaction body" + . T.replace "value of type AlonzoTxBodyRaw" "transaction body" + . T.replace "value of type AllegraTxBodyRaw ()" "transaction body" + . T.replace "value of type AllegraTxBodyRaw MultiAsset" "transaction body" + . T.replace "value of type ShelleyTxBodyRaw" "transaction body" + . T.replace "atbr" "" + . T.replace "stbr" "" + . T.replace "btbr" "" + . T.replace "ctbr" "" diff --git a/testgen-hs/Evaluation.hs b/testgen-hs/Evaluation.hs new file mode 100644 index 0000000..ab4afe8 --- /dev/null +++ b/testgen-hs/Evaluation.hs @@ -0,0 +1,150 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE PatternSynonyms #-} +{-# LANGUAGE RankNTypes #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# OPTIONS_GHC -Wno-orphans #-} + +module Evaluation + ( WrappedTransactionScriptFailure (..), + writeJson, + eval'Conway, + ) +where + +import CLI (GenSize (..), NumCases (..), Seed (..)) +import Cardano.Api.Internal.Orphans () +import Cardano.Ledger.Alonzo.Plutus.Evaluate (evalTxExUnits) +import Cardano.Ledger.Alonzo.Scripts (AsIx (..), ExUnits (..)) +import Cardano.Ledger.Api (ConwayEra, PParams, TransactionScriptFailure) +import qualified Cardano.Ledger.Api as Ledger +import Cardano.Ledger.Api.Tx (PlutusPurpose, RedeemerReport, Tx) +import Cardano.Ledger.Api.UTxO (UTxO (..)) +import Cardano.Slotting.EpochInfo (EpochInfo) +import Cardano.Slotting.Slot () +import Cardano.Slotting.Time (SystemStart (..)) +import Data.Aeson (ToJSON) +import qualified Data.Aeson as J +import qualified Data.Aeson.Encoding as AesonEncoding +import qualified Data.ByteString.Lazy.Char8 as BL8 +import Data.List (sortOn) +import qualified Data.Map as Map +import Data.Proxy (Proxy) +import Data.Text (Text) +import Encoder (ogmiosSuccess, serializeTransactionScriptFailure) +import PlutusCore.Evaluation.Machine.ExBudget (ExBudget (..)) +import PlutusCore.Evaluation.Machine.ExMemory (ExCPU (..), ExMemory (..)) +import Test.Consensus.Cardano.Generators () +import qualified Test.QuickCheck as QC +import qualified Test.QuickCheck.Gen as QGen +import qualified Test.QuickCheck.Random as QCRandom + +newtype WrappedTransactionScriptFailure era = WrappedTransactionScriptFailure + { unWrappedTransactionScriptFailure :: + TransactionScriptFailure era + } + +instance ToJSON (WrappedTransactionScriptFailure ConwayEra) where + toEncoding (WrappedTransactionScriptFailure x) = + serializeTransactionScriptFailure x + toJSON wrapped = + case J.decode (AesonEncoding.encodingToLazyByteString (J.toEncoding wrapped)) :: Maybe J.Value of + Just v -> v + Nothing -> + error "serializeTransactionScriptFailure produced invalid JSON" + +instance QC.Arbitrary (Ledger.TransactionScriptFailure Ledger.ConwayEra) where + arbitrary = + QC.oneof + [ Ledger.RedeemerPointsToUnknownScriptHash <$> QC.arbitrary, + (`Ledger.MissingScript` Map.empty) <$> QC.arbitrary, + Ledger.MissingDatum <$> QC.arbitrary, + Ledger.UnknownTxIn <$> QC.arbitrary, + Ledger.InvalidTxIn <$> QC.arbitrary, + pure $ Ledger.IncompatibleBudget (ExBudget (ExCPU 999) (ExMemory 888)), + Ledger.NoCostModelInLedgerState <$> QC.arbitrary, + Ledger.ContextError <$> QC.arbitrary + ] + +writeJson :: + Proxy ConwayEra -> + Seed -> + GenSize -> + NumCases -> + IO () +writeJson _ (Seed seed) (GenSize size) (NumCases numCases) = do + let gen :: QGen.Gen [WrappedTransactionScriptFailure ConwayEra] + gen = + QC.vectorOf numCases $ + WrappedTransactionScriptFailure <$> (QC.arbitrary :: QC.Gen (TransactionScriptFailure ConwayEra)) + xs :: [WrappedTransactionScriptFailure ConwayEra] + xs = QGen.unGen gen (QCRandom.mkQCGen seed) size + BL8.putStrLn (J.encode xs) + +eval'Conway :: + PParams ConwayEra -> + Tx ConwayEra -> + UTxO ConwayEra -> + EpochInfo (Either Text) -> + SystemStart -> + J.Value +eval'Conway pparams tx utxo epochInfo systemStart = + case J.decode (AesonEncoding.encodingToLazyByteString (ogmiosSuccess redeemerReport)) of + Just v -> v + Nothing -> error "ogmiosSuccess produced invalid JSON" + where + redeemerReport :: RedeemerReport ConwayEra + redeemerReport = selectSingleReport fullReport + + fullReport :: RedeemerReport ConwayEra + fullReport = evalTxExUnits pparams tx utxo epochInfo systemStart + + -- Collapse the full report down to a single entry, preferring failures. + selectSingleReport :: RedeemerReport ConwayEra -> RedeemerReport ConwayEra + selectSingleReport report = + case Map.toList failures of + (purpose, errors) : _ -> + Map.singleton purpose (Left (pickScriptFailure errors)) + [] -> + case Map.toList successes of + (purpose, exUnits) : _ -> + Map.singleton purpose (Right exUnits) + [] -> + error "Empty redeemer report from evalTxExUnits" + where + (failures, successes) = Map.foldrWithKey groupReports (Map.empty, Map.empty) report + + groupReports :: + (Ord (PlutusPurpose AsIx era)) => + PlutusPurpose AsIx era -> + Either (Ledger.TransactionScriptFailure era) ExUnits -> + (Map.Map (PlutusPurpose AsIx era) [Ledger.TransactionScriptFailure era], Map.Map (PlutusPurpose AsIx era) ExUnits) -> + (Map.Map (PlutusPurpose AsIx era) [Ledger.TransactionScriptFailure era], Map.Map (PlutusPurpose AsIx era) ExUnits) + groupReports purpose result (failures, successes) = + case result of + Left scriptFail -> (Map.unionWith (++) (Map.singleton purpose [scriptFail]) failures, successes) + Right exUnits -> (failures, Map.singleton purpose exUnits <> successes) + +-- | Return the most relevant script failure from a list of errors. +pickScriptFailure :: + [Ledger.TransactionScriptFailure era] -> + Ledger.TransactionScriptFailure era +pickScriptFailure xs = + case sortOn scriptFailurePriority xs of + [] -> error "Empty list of script failures from the ledger!?" + x : _ -> x + where + scriptFailurePriority :: + Ledger.TransactionScriptFailure era -> + Word + scriptFailurePriority = \case + Ledger.UnknownTxIn {} -> 0 + Ledger.MissingScript {} -> 0 + Ledger.RedeemerPointsToUnknownScriptHash {} -> 1 + Ledger.NoCostModelInLedgerState {} -> 1 + Ledger.InvalidTxIn {} -> 2 + Ledger.MissingDatum {} -> 3 + Ledger.ContextError {} -> 4 + Ledger.ValidationFailure {} -> 5 + Ledger.IncompatibleBudget {} -> 999 diff --git a/testgen-hs/Generators.hs b/testgen-hs/Generators.hs index 36b42a3..612453c 100644 --- a/testgen-hs/Generators.hs +++ b/testgen-hs/Generators.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE FlexibleInstances #-} {-# OPTIONS_GHC -fno-warn-orphans #-} module Generators where @@ -23,6 +24,7 @@ import Data.Text (Text) import qualified Data.Text.Encoding as T import qualified Data.Text.Encoding.Error as T import Data.Typeable (Typeable, typeOf) +import Evaluation () import GHC.Generics (Generic) import Generic.Random (GenericArbitraryU (..)) import qualified Ouroboros.Consensus.Byron.Ledger as OCBL @@ -181,7 +183,7 @@ instance OurCBOR Tx'Conway where ourToCBOR (Tx'Conway (tx, _utxo)) = Cardano.Binary.toCBOR tx ourToJSON (Tx'Conway (tx, utxo)) = J.object - [ "executionUnits" J..= SynthEvalTx.eval'Conway tx utxo, + [ "executionUnits" J..= SynthEvalTx.eval'ConwayDummy tx utxo, "utxoSetCBOR" J..= ( T.decodeUtf8With T.lenientDecode . B16.encode diff --git a/testgen-hs/Main.hs b/testgen-hs/Main.hs index fd79be0..7244e04 100644 --- a/testgen-hs/Main.hs +++ b/testgen-hs/Main.hs @@ -1,14 +1,29 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE TypeApplications #-} + module Main where import CLI (GenSize (..), NumCases (..), Seed (..)) import qualified CLI +import Cardano.Binary (FromCBOR, decodeFull') +import Cardano.Ledger.Api (ConwayEra, PParams) +import qualified Cardano.Ledger.Binary.Decoding as Binary +import qualified Cardano.Ledger.Core +import qualified Cardano.Ledger.Core as Ledger +import Cardano.Slotting.EpochInfo (EpochInfo, fixedEpochInfo) +import Cardano.Slotting.Slot (EpochSize (..)) +import Cardano.Slotting.Time (SystemStart (..), mkSlotLength) import qualified Codec.CBOR.Write as C import qualified Control.Concurrent.Async as Async import Control.Concurrent.MVar (modifyMVar_, newMVar) +import Control.Monad (forever, when) import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Aeson.Encode.Pretty as J +import Data.Bifunctor (first) import Data.ByteString (ByteString) +import qualified Data.ByteString as BS import qualified Data.ByteString.Base16 as B16 import qualified Data.ByteString.Char8 as B8 import qualified Data.ByteString.Lazy as BL @@ -18,12 +33,16 @@ import Data.Proxy (Proxy (..)) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as T -import qualified Data.Text.Encoding.Error as T +import qualified Data.Text.Encoding.Error as TEE +import Data.Time (NominalDiffTime) import Data.Time.Clock.POSIX (getPOSIXTime) +import Data.Word (Word16, Word64) import qualified Deserialize as D +import Encoder (serializeDecoderError) +import Evaluation (eval'Conway, writeJson) import GHC.Generics (Generic) import qualified Generators as G -import System.Exit (exitFailure) +import System.Exit (exitFailure, exitSuccess) import System.IO (hPutStrLn, stderr) import qualified System.IO as SIO import qualified System.Random @@ -39,6 +58,7 @@ main = CLI.Generate opts -> runGenerate opts CLI.Deserialize cbor -> runDeserialize cbor CLI.DeserializeStream -> runDeserializeStream + CLI.EvaluateStream -> runEvaluateStream data TestCase a = TestCase { cbor :: Text, @@ -48,6 +68,61 @@ data TestCase a = TestCase } deriving (Generic, Show, FromJSON, ToJSON) +-- | Represents initialization payload containing configuration parameters. +-- +-- The InitPayload type encapsulates essential system and protocol configuration for eval tx: +-- +-- - systemStart: Starting time of the system, CBOR +-- - protocolParams: Protocol parameters in text format, CBOR +-- - slotConfig: Configuration for slots +-- - era: Era identifier as a 16-bit word +-- +-- This data type implements Generic, Show and FromJSON type classes for serialization +-- and debugging purposes. +data InitPayload = InitPayload + { systemStart :: Text, -- cbor + protocolParams :: Text, -- cbor + slotConfig :: SlotConfig, + era :: Word16 + } + deriving (Generic, Show, FromJSON) + +data SlotConfig = SlotConfig + { slotLength :: Word64, -- in milliseconds + zeroSlot :: Word64, + zeroTime :: Word64, + epochLength :: Word64 + } + deriving (Generic, Show, FromJSON) + +-- This is used as a generic response to any incoming request. +data PayloadResponse = PayloadResponse + { rError :: Maybe Text, + rJson :: Maybe J.Value + } + deriving (Generic, Show) + +instance ToJSON PayloadResponse where + toJSON = J.genericToJSON J.defaultOptions {J.fieldLabelModifier = modifier, J.omitNothingFields = True} + where + modifier "rError" = "error" + modifier "rJson" = "json" + modifier s = s + +-- | +-- EvalPayload represents the data structure for transaction evaluation payload. +-- +-- Contains transaction and UTxO information needed for evaluation. +-- +-- Fields: +-- * tx - Transaction data as CBOR +-- * utxos - Unspent Transaction Outputs (UTxOs) as CBOR +data EvalPayload = EvalPayload + { tx :: Text, + utxos :: Text -- TxIn & TxOut as key value pair CBOR (map) + } + deriving (Generic, Show, FromJSON) + runGenerate :: CLI.GenerateOptions -> IO () runGenerate (CLI.GenerateOptions maybeSeed genSize numCases command) = do SIO.hSetBuffering SIO.stdout SIO.LineBuffering @@ -59,7 +134,7 @@ runGenerate (CLI.GenerateOptions maybeSeed genSize numCases command) = do BL8.putStrLn $ J.encode (J.object [("seed", (\(Seed s) -> J.toJSON s) seed)]) ( case command of - CLI.Tx'Conway -> writeRandom @G.Tx'Conway Proxy + CLI.Tx'ConwayDummy -> writeRandom @G.Tx'Conway Proxy CLI.ApplyTxErr'Byron -> writeRandom @G.ApplyTxErr'Byron Proxy CLI.ApplyTxErr'Shelley -> writeRandom @G.ApplyTxErr'Shelley Proxy CLI.ApplyTxErr'Allegra -> writeRandom @G.ApplyTxErr'Allegra Proxy @@ -67,6 +142,7 @@ runGenerate (CLI.GenerateOptions maybeSeed genSize numCases command) = do CLI.ApplyTxErr'Alonzo -> writeRandom @G.ApplyTxErr'Alonzo Proxy CLI.ApplyTxErr'Babbage -> writeRandom @G.ApplyTxErr'Babbage Proxy CLI.ApplyTxErr'Conway -> writeRandom @G.ApplyTxErr'Conway Proxy + CLI.TxScriptFailure'Conway -> writeJson Proxy CLI.DataText -> writeRandom @Text Proxy CLI.GHCInteger -> writeRandom @Integer Proxy CLI.ExampleADT -> writeRandom @G.ExampleADT Proxy @@ -118,7 +194,7 @@ mkTestCase :: forall a. (Show a, G.OurCBOR a) => a -> TestCase a mkTestCase a = TestCase { cbor = - T.decodeUtf8With T.lenientDecode + T.decodeUtf8With TEE.lenientDecode . B16.encode . BL.toStrict . C.toLazyByteString @@ -149,7 +225,7 @@ cborToTestCase cbor' = let (typeTag', haskellRepr') = G.hfcEnvelopeShowInner a in TestCase { cbor = - T.decodeUtf8With T.lenientDecode + T.decodeUtf8With TEE.lenientDecode . B16.encode $ cbor', haskellRepr = T.pack haskellRepr', @@ -176,3 +252,98 @@ runDeserializeStream = do Left err -> J.object ["error" J..= err] BL8.putStrLn . J.encode $ result processLines + +runEvaluateStream :: IO () +runEvaluateStream = do + -- We need line buffering for this to work: + SIO.hSetBuffering SIO.stdin SIO.LineBuffering + SIO.hSetBuffering SIO.stdout SIO.LineBuffering + line <- B8.getLine + case J.eitherDecodeStrict line of + Left err -> do + BL8.putStrLn . J.encode $ + PayloadResponse + { rJson = Nothing, + rError = Just . T.pack $ "Expected InitPayload first, but failed to parse line: " ++ err + } + runEvaluateStream + Right initPayload -> do + let ei = convertEpochInfo (slotConfig initPayload) + let combinedResult = do + pp <- decodeFromHex (protocolParams initPayload) + ss <- decodeFromHex (systemStart initPayload) + return (pp, ss) + + case combinedResult of + Left err -> do + BL8.putStrLn . J.encode $ + PayloadResponse + { rJson = Nothing, + rError = Just . T.pack $ "Failed to decode initial payload " ++ err + } + exitFailure + Right (pp, ss) -> do + BL8.putStrLn . J.encode $ PayloadResponse {rJson = Just (J.object []), rError = Nothing} + forever $ processLine pp ss ei + where + processLine :: PParams ConwayEra -> SystemStart -> EpochInfo (Either Text) -> IO () + processLine pp ss ei = do + eof <- SIO.isEOF + when eof exitSuccess + line <- B8.getLine -- This line is expected to be an EvalPayload + case J.eitherDecodeStrict line of + Left err -> do + let response = case J.eitherDecodeStrict' (B8.pack err) of + Left decodeErr -> PayloadResponse {rJson = Nothing, rError = Just (T.pack $ "Failed to decode error as JSON: " ++ decodeErr ++ ". Original error: " ++ err)} + Right jsonVal -> PayloadResponse {rJson = Nothing, rError = Just jsonVal} + BL8.putStrLn . J.encode $ response + Right (evalPayload :: EvalPayload) -> do + let decodedValues = do + txBytes <- first (\e -> PayloadResponse (Just (T.pack e)) Nothing) $ B16.decode (T.encodeUtf8 (tx evalPayload)) + decodedTx <- + decodeCborWith + "Transaction" + (Left . (\e -> PayloadResponse (Just (serializeDecoderError (BS.length txBytes) e)) Nothing)) + (Binary.decCBOR @(Cardano.Ledger.Core.Tx ConwayEra)) + txBytes + utxos <- first (\e -> PayloadResponse (Just (T.pack e)) Nothing) $ decodeFromHex (utxos evalPayload) + return (decodedTx, utxos) + + case decodedValues of + Left response -> + BL8.putStrLn . J.encode $ response + Right (tx, utxos) -> do + let result = eval'Conway pp tx utxos ei ss + BL8.putStrLn . J.encode $ PayloadResponse {rJson = Just result, rError = Nothing} + +-- | Creates an EpochInfo from the given SlotConfig +convertEpochInfo :: SlotConfig -> EpochInfo (Either Text) +convertEpochInfo sc = + let slotLengthInSeconds :: NominalDiffTime = fromIntegral (slotLength sc) / 1000 + in fixedEpochInfo (EpochSize $ epochLength sc) (mkSlotLength slotLengthInSeconds) + +-- | Generic CBOR hex decoder +decodeFromHex :: (FromCBOR a) => T.Text -> Either String a +decodeFromHex hexText = do + -- 1. Decode from hex + cborBytes <- first show $ B16.decode (T.encodeUtf8 hexText) + -- 2. Decode from CBOR. + first show $ decodeFull' cborBytes + +-- Run a CBOR decoder for data in Conway era +decodeCborWith :: + -- | Label for error reporting + Text -> + -- | Error handler + (Binary.DecoderError -> Either e a) -> + -- | CBOR decoder + (forall s. Binary.Decoder s a) -> + -- | Input bytes + ByteString -> + Either e a +decodeCborWith lbl handleErr decoder bytes = + case Binary.decodeFullDecoder version lbl decoder (BL.fromStrict bytes) of + Left cborErr -> handleErr cborErr + Right val -> Right val + where + version = Ledger.eraProtVerLow @ConwayEra diff --git a/testgen-hs/Response.hs b/testgen-hs/Response.hs new file mode 100644 index 0000000..301aac8 --- /dev/null +++ b/testgen-hs/Response.hs @@ -0,0 +1,28 @@ +module Response + ( PayloadResponse (..), + ) +where + +import Data.Aeson (ToJSON) +import qualified Data.Aeson as J +import Data.Text (Text) +import GHC.Generics (Generic) + +-- | Generic response envelope used across endpoints. +data PayloadResponse = PayloadResponse + { rError :: Maybe Text, + rJson :: Maybe J.Value + } + deriving (Generic, Show) + +instance ToJSON PayloadResponse where + toJSON = + J.genericToJSON + J.defaultOptions + { J.fieldLabelModifier = modifier, + J.omitNothingFields = True + } + where + modifier "rError" = "error" + modifier "rJson" = "json" + modifier s = s diff --git a/testgen-hs/SynthEvalTx.hs b/testgen-hs/SynthEvalTx.hs index d5cddbb..17b3e3e 100644 --- a/testgen-hs/SynthEvalTx.hs +++ b/testgen-hs/SynthEvalTx.hs @@ -9,29 +9,16 @@ -- | Generates a fake minimal UTxO set for a given transaction, for the purposes -- of running `Cardano.Ledger.Alonzo.Plutus.Evaluate.evalTxExUnits` on the -- transaction. -module SynthEvalTx (eval'Conway, genTxUTxO, stubUTxO) where +module SynthEvalTx (eval'Conway, eval'ConwayDummy, genTxUTxO, stubUTxO) where import Cardano.Crypto.Hash.Class (hashFromBytes) import Cardano.Ledger.Address (Addr (..)) import Cardano.Ledger.Alonzo.Plutus.Evaluate (evalTxExUnits) -import Cardano.Ledger.Alonzo.Scripts - ( AsIx (..), - ExUnits (..), - ) import qualified Cardano.Ledger.Alonzo.Scripts import qualified Cardano.Ledger.Alonzo.Tx import Cardano.Ledger.Api (EraTx, PParams, bodyTxL, collateralInputsTxBodyL, inputsTxBodyL, referenceInputsTxBodyL) import qualified Cardano.Ledger.Api.Era -import Cardano.Ledger.Api.Scripts - ( ConwayEraScript, - pattern CertifyingPurpose, - pattern MintingPurpose, - pattern ProposingPurpose, - pattern RewardingPurpose, - pattern SpendingPurpose, - pattern VotingPurpose, - ) -import Cardano.Ledger.Api.Tx (BabbageEraTxBody, PlutusPurpose, RedeemerReport, Tx) +import Cardano.Ledger.Api.Tx (BabbageEraTxBody, RedeemerReport, Tx) import Cardano.Ledger.Api.Tx.In (TxIn) import Cardano.Ledger.Api.Tx.Out ( TxOut (..), @@ -53,15 +40,16 @@ import Data.Aeson ( eitherDecodeStrict', ) import qualified Data.Aeson as J +import qualified Data.Aeson.Encoding as AesonEncoding import qualified Data.ByteString as BS import qualified Data.Default import Data.FileEmbed (embedFile) import qualified Data.Map.Strict as Map import qualified Data.Set as Set import Data.Text (Text) -import qualified Data.Text as T import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (UTCTime (..), secondsToDiffTime) +import Encoder (ogmiosSuccess) import Lens.Micro ((^.)) import qualified Test.Cardano.Ledger.Generic.GenState import qualified Test.Cardano.Ledger.Generic.Proof as Proof @@ -94,8 +82,26 @@ genTxUTxO = do proof :: Proof.Proof Cardano.Ledger.Api.Era.ConwayEra proof = Proof.Conway -eval'Conway :: (Cardano.Ledger.Core.Tx (Cardano.Ledger.Api.Era.ConwayEra)) -> UTxO Cardano.Ledger.Api.Era.ConwayEra -> J.Value -eval'Conway tx utxo = ogmiosSuccess redeemerReport +eval'Conway :: + (Cardano.Ledger.Core.Tx (Cardano.Ledger.Api.Era.ConwayEra)) -> + UTxO (Cardano.Ledger.Api.Era.ConwayEra) -> + EpochInfo (Either Text) -> + SystemStart -> + J.Value +eval'Conway tx utxo epochInfo systemStart = + case J.decode (AesonEncoding.encodingToLazyByteString (ogmiosSuccess redeemerReport)) of + Just v -> v + Nothing -> error "ogmiosSuccess produced invalid JSON" + where + redeemerReport :: RedeemerReport (Cardano.Ledger.Api.Era.ConwayEra) + redeemerReport = evalTxExUnits protocolParams tx utxo epochInfo systemStart + +-- | Version of eval'Conway that uses dummy epoch info and system start +eval'ConwayDummy :: (Cardano.Ledger.Core.Tx (Cardano.Ledger.Api.Era.ConwayEra)) -> UTxO Cardano.Ledger.Api.Era.ConwayEra -> J.Value +eval'ConwayDummy tx utxo = + case J.decode (AesonEncoding.encodingToLazyByteString (ogmiosSuccess redeemerReport)) of + Just v -> v + Nothing -> error "ogmiosSuccess produced invalid JSON" where redeemerReport :: RedeemerReport (Cardano.Ledger.Api.Era.ConwayEra) redeemerReport = evalTxExUnits protocolParams tx utxo dummyEpochInfo dummySystemStart @@ -142,46 +148,3 @@ protocolParams = case eitherDecodeStrict' protocolParamsJSON of Left err -> error $ "Embedded protocol-parameters JSON is malformed:\n" <> err Right pp -> pp - --- | Render a ledger redeemer pointer the Ogmios way -redeemerPtrToText :: forall era. (ConwayEraScript era) => PlutusPurpose AsIx era -> Text -redeemerPtrToText = \case - SpendingPurpose (AsIx ix) -> "spend:" <> showT ix - CertifyingPurpose (AsIx ix) -> "publish:" <> showT ix - MintingPurpose (AsIx ix) -> "mint:" <> showT ix - RewardingPurpose (AsIx ix) -> "withdraw:" <> showT ix - ProposingPurpose (AsIx ix) -> "propose:" <> showT ix - VotingPurpose (AsIx ix) -> "vote:" <> showT ix - _ -> error "unreachable: unknown PlutusPurpose" -- matches _are_ exhaustive, but it’s not provable statically - where - showT = T.pack . show -- Word32 → Text - --- | Ogmios "budget" object from 'ExUnits'. -exUnitsToJSON :: ExUnits -> J.Value -exUnitsToJSON (ExUnits mem cpu) = - J.object ["memory" J..= mem, "cpu" J..= cpu] - --- | Build the JSON-RPC success envelope that Ogmios returns when every --- redeemer succeeds. -ogmiosSuccess :: - forall era err. - (ConwayEraScript era, Show err) => -- same constraint here - Map.Map (PlutusPurpose AsIx era) (Either err ExUnits) -> - J.Value -ogmiosSuccess report = - J.toJSON - [ case res of - Right exu -> - J.object - [ "validator" J..= redeemerPtrToText ptr, - "budget" J..= exUnitsToJSON exu - ] - Left err -> - J.object - [ "validator" J..= redeemerPtrToText ptr, - "error" - J..= J.object - ["message" J..= show err] -- simple text trace - ] - | (ptr, res) <- Map.toList report - ] diff --git a/testgen-hs/testgen-hs.cabal b/testgen-hs/testgen-hs.cabal index 06ab9df..6d281fc 100644 --- a/testgen-hs/testgen-hs.cabal +++ b/testgen-hs/testgen-hs.cabal @@ -5,7 +5,7 @@ synopsis: CBOR test case generator and deserializer for cross-checking other implementations -- Version tracks cardano-node, adding a patch segment: -version: 10.4.1.1 +version: 10.4.1.2 executable testgen-hs main-is: Main.hs @@ -21,7 +21,10 @@ executable testgen-hs , cardano-crypto-class , cardano-ledger-alonzo , cardano-ledger-api + , cardano-ledger-babbage + , cardano-ledger-binary , cardano-ledger-byron + , cardano-ledger-conway , cardano-ledger-core , cardano-ledger-test , cardano-slotting @@ -36,6 +39,9 @@ executable testgen-hs , ouroboros-consensus , ouroboros-consensus-cardano , ouroboros-consensus-cardano:unstable-cardano-testlib + , plutus-core + , plutus-ledger-api + , prettyprinter , QuickCheck , quickcheck-instances , random @@ -43,11 +49,16 @@ executable testgen-hs , text , time + --, cardano-crypto + --, cardano-crypto-wrapper other-modules: CLI Deserialize + Encoder + Evaluation Generators Paths_testgen_hs + Response SynthEvalTx default-extensions: