Merge branch 'master' into dev-sym-exec-fixes

This commit is contained in:
Gustavo Grieco
2025-09-26 19:39:15 -03:00
committed by GitHub
20 changed files with 1481 additions and 58 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ on:
env:
# Tag for cache invalidation
CACHE_VERSION: v9
CACHE_VERSION: v10
jobs:
build:
@@ -23,7 +23,7 @@ jobs:
- os: ubuntu-latest
shell: bash
container: "{\"image\": \"elopeztob/alpine-haskell-stack-echidna:ghc-9.8.4\", \"options\": \"--user 1001\"}"
- os: macos-13 # x86_64 macOS
- os: macos-15-intel # x86_64 macOS
shell: bash
- os: windows-latest
shell: msys2 {0}
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
name: Linux (aarch64)
tuple: aarch64-linux
timeout: 180
- os: macos-13
- os: macos-15-intel
name: macOS (x86_64)
tuple: x86_64-macos
- os: macos-14
+13 -5
View File
@@ -4,22 +4,24 @@ import Control.Concurrent (newChan)
import Control.Monad.Catch (MonadThrow(..))
import Control.Monad.ST (RealWorld)
import Data.IORef (newIORef)
import Data.List (find)
import Data.List (find, nub)
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe)
import Data.Set qualified as Set
import System.FilePath ((</>))
import EVM (cheatCode)
import EVM.ABI (AbiValue(AbiAddress))
import EVM.Dapp (dappInfo)
import EVM.Solidity (BuildOutput(..), Contracts(Contracts))
import EVM.Solidity (BuildOutput(..), Contracts(Contracts), Method(..), Mutability(..), SolcContract(..))
import EVM.Types hiding (Env)
import Echidna.ABI
import Echidna.Onchain as Onchain
import Echidna.Output.Corpus
import Echidna.SourceMapping (findSrcForReal)
import Echidna.SourceAnalysis.Slither
import Echidna.Solidity
import Echidna.SymExec.Symbolic (forceAddr)
@@ -72,7 +74,6 @@ prepareContract cfg solFiles buildOutput selectedContract seed = do
-- deploy contracts
vm <- loadSpecified env mainContract contracts
let
deployedAddresses = Set.fromList $ AbiAddress . forceAddr <$> Map.keys vm.env.contracts
constants = enhanceConstants slitherInfo
@@ -80,14 +81,21 @@ prepareContract cfg solFiles buildOutput selectedContract seed = do
<> extremeConstants
<> staticAddresses solConf
<> deployedAddresses
deployedSolcContracts = nub $ mapMaybe (findSrcForReal env.dapp) $ Map.elems vm.env.contracts
nonViewPureSigs = concatMap (mapMaybe (\ (Method {name, inputs, mutability}) ->
case mutability of
View -> Nothing
Pure -> Nothing
Payable -> Just (name, map snd inputs)
NonPayable -> Just (name, map snd inputs))
. Map.elems . (\ (SolcContract {abiMap}) -> abiMap)) deployedSolcContracts
dict = mkGenDict env.cfg.campaignConf.dictFreq
-- make sure we don't use cheat codes to form fuzzing call sequences
(Set.delete (AbiAddress $ forceAddr cheatCode) constants)
Set.empty
seed
(returnTypes contracts)
nonViewPureSigs
pure (vm, env, dict)
loadInitialCorpus :: Env -> IO [(FilePath, [Tx])]
+43 -21
View File
@@ -1,6 +1,6 @@
module Echidna.ABI where
import Control.Monad (liftM2, liftM3, foldM, replicateM)
import Control.Monad (liftM2, liftM3, foldM, replicateM, zipWithM)
import Control.Monad.Random.Strict (MonadRandom, join, getRandom, getRandoms, getRandomR, uniform, fromList)
import Control.Monad.Random.Strict qualified as Random
import Data.Binary.Put (runPut, putWord32be)
@@ -121,6 +121,8 @@ data GenDict = GenDict
-- ^ Return types of any methods we scrape return values from
, dictValues :: !(Set W256)
-- ^ A set of int/uint constants for better performance
, callbackSigs :: ![SolSignature]
-- ^ A list of callback signatures (for generating random callbacks)
}
hashMapBy
@@ -142,6 +144,7 @@ mkGenDict
-> Set SolCall -- ^ A list of complete 'SolCall's to mutate
-> Int -- ^ A default seed
-> (Text -> Maybe AbiType) -- ^ A return value typing rule
-> [SolSignature]
-> GenDict
mkGenDict mutationChance abiValues solCalls seed typingRule =
GenDict mutationChance
@@ -152,7 +155,7 @@ mkGenDict mutationChance abiValues solCalls seed typingRule =
(mkDictValues abiValues)
emptyDict :: GenDict
emptyDict = mkGenDict 0 Set.empty Set.empty 0 (const Nothing)
emptyDict = mkGenDict 0 Set.empty Set.empty 0 (const Nothing) []
mkDictValues :: Set AbiValue -> Set W256
mkDictValues =
@@ -382,31 +385,50 @@ pregenAbiAdds = map (AbiAddress . fromIntegral) pregenAdds
-- | Synthesize a random 'AbiValue' given its 'AbiType'. Requires a dictionary.
-- Only produce lists with number of elements in the range [1, 32]
genAbiValueM :: MonadRandom m => GenDict -> AbiType -> m AbiValue
genAbiValueM genDict = genWithDict genDict genDict.constants $ \case
AbiUIntType n -> fixAbiUInt n . fromInteger <$> getRandomUint n
AbiIntType n -> fixAbiInt n . fromInteger <$> getRandomInt n
AbiAddressType -> rElem $ NE.fromList pregenAbiAdds
AbiBoolType -> AbiBool <$> getRandom
AbiBytesType n -> AbiBytes n . BS.pack . take n <$> getRandoms
AbiBytesDynamicType -> liftM2 (\n -> AbiBytesDynamic . BS.pack . take n)
(getRandomR (1, 32)) getRandoms
AbiStringType -> liftM2 (\n -> AbiString . BS.pack . take n)
(getRandomR (1, 32)) getRandoms
AbiArrayDynamicType t -> fmap (AbiArrayDynamic t) $ getRandomR (1, 32)
>>= flip V.replicateM (genAbiValueM genDict t)
AbiArrayType n t -> AbiArray n t <$> V.replicateM n (genAbiValueM genDict t)
AbiTupleType v -> AbiTuple <$> traverse (genAbiValueM genDict) v
AbiFunctionType -> liftM2 (\n -> AbiString . BS.pack . take n)
(getRandomR (1, 32)) getRandoms
genAbiValueM genDict = genAbiValueM' genDict "" 0
genAbiValueM' :: MonadRandom m => GenDict -> Text -> Int -> AbiType -> m AbiValue
genAbiValueM' genDict funcName depth t =
let go = \case
AbiUIntType n -> fixAbiUInt n . fromInteger <$> getRandomUint n
AbiIntType n -> fixAbiInt n . fromInteger <$> getRandomInt n
AbiAddressType -> rElem $ NE.fromList pregenAbiAdds
AbiBoolType -> AbiBool <$> getRandom
AbiBytesType n -> AbiBytes n . BS.pack . take n <$> getRandoms
AbiBytesDynamicType ->
let
filteredSigs = filter ((/= funcName) . fst) genDict.callbackSigs
in if null filteredSigs || depth >= 2 then
liftM2 (\n -> AbiBytesDynamic . BS.pack . take n)
(getRandomR (1, 32)) getRandoms
else
join $ Random.weighted
[ (do
sig@(_, types) <- uniform filteredSigs
params <- V.fromList <$> mapM (genAbiValueM' genDict "" $ depth + 1) types
pure $ AbiBytesDynamic (abiCalldata (encodeSig sig) params), 9)
, (liftM2 (\n -> AbiBytesDynamic . BS.pack . take n)
(getRandomR (1, 8)) getRandoms, 1)
]
AbiStringType -> liftM2 (\n -> AbiString . BS.pack . take n)
(getRandomR (1, 32)) getRandoms
AbiArrayDynamicType t' -> fmap (AbiArrayDynamic t') $ getRandomR (1, 32)
>>= flip V.replicateM (genAbiValueM' genDict funcName (depth + 1) t')
AbiArrayType n t' -> AbiArray n t' <$> V.replicateM n (genAbiValueM' genDict funcName (depth + 1) t')
AbiTupleType v -> AbiTuple <$> traverse (genAbiValueM' genDict funcName (depth + 1)) v
AbiFunctionType -> liftM2 (\n -> AbiString . BS.pack . take n)
(getRandomR (1, 32)) getRandoms
in genWithDict genDict genDict.constants go t
-- | Given a 'SolSignature', generate a random 'SolCall' with that signature,
-- possibly with a dictionary.
genAbiCallM :: MonadRandom m => GenDict -> SolSignature -> m SolCall
genAbiCallM genDict abi = do
genAbiCallM genDict (name, types) = do
let genVals = zipWithM (flip (genAbiValueM' genDict name)) types (repeat 0)
solCall <- genWithDict genDict
genDict.wholeCalls
(traverse $ traverse (genAbiValueM genDict))
abi
(const ((name,) <$> genVals))
(name, types)
mutateAbiCall solCall
-- | Given a list of 'SolSignature's, generate a random 'SolCall' for one,
+1
View File
@@ -98,6 +98,7 @@ instance FromJSON EConfigWithUsage where
<*> v ..:? "coverageDir" ..!= Nothing
<*> v ..:? "mutConsts" ..!= defaultMutationConsts
<*> v ..:? "coverageFormats" ..!= [Txt,Html,Lcov]
<*> v ..:? "coverageExcludes" ..!= []
<*> v ..:? "workers"
<*> v ..:? "server"
<*> v ..:? "symExec" ..!= False
+96
View File
@@ -0,0 +1,96 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TemplateHaskell #-}
module Echidna.Output.Foundry (foundryTest) where
import Data.Aeson (Value(..), object, (.=))
import Data.List (elemIndex, nub)
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Text (Text, unpack)
import Data.Text.Encoding (decodeUtf8)
import qualified Data.Text.Lazy as TL
import Data.Text.Lazy (fromStrict)
import Data.Vector as V hiding ((++), map, zipWith, elemIndex, mapMaybe)
import EVM.ABI (AbiValue(..))
import EVM.Types (W256, Addr)
import Numeric (showHex)
import Text.Mustache (Template, substituteValue, toMustache)
import Text.Mustache.Compile (embedTemplate)
import Echidna.Types.Test (EchidnaTest(..), TestType(..))
import Echidna.Types.Tx (Tx(..), TxCall(..))
template :: Template
template = $(embedTemplate ["lib/Echidna/Output/assets"] "foundry.mustache")
-- | Generate a Foundry test from an EchidnaTest result.
foundryTest :: Maybe Text -> EchidnaTest -> TL.Text
foundryTest mContractName test =
case test.testType of
AssertionTest{} ->
let testData = createTestData mContractName test
in fromStrict $ substituteValue template (toMustache testData)
_ -> ""
-- | Create an Aeson Value from test data for the Mustache template.
createTestData :: Maybe Text -> EchidnaTest -> Value
createTestData mContractName test =
let
senders = nub $ map (.src) test.reproducer
actors = zipWith actorObject senders [1..]
repro = mapMaybe (foundryTx senders) test.reproducer
cName = fromMaybe "YourContract" mContractName
in
object
[ "testName" .= ("Test" :: Text)
, "contractName" .= cName
, "actors" .= actors
, "reproducer" .= repro
]
-- | Create a JSON object for an actor.
actorObject :: Addr -> Int -> Value
actorObject sender i = object
[ "name" .= ("USER" ++ show i :: String)
, "address" .= formatAddr sender
]
-- | Format an address for Solidity.
formatAddr :: Addr -> String
formatAddr addr = "address(0x" ++ showHex (fromIntegral addr :: W256) "" ++ ")"
-- | Generate a single transaction line for the reproducer.
foundryTx :: [Addr] -> Tx -> Maybe Value
foundryTx senders tx =
case tx.call of
SolCall (name, args) ->
let
(time, blocks) = tx.delay
senderName =
case elemIndex tx.src senders of
Just i -> "USER" ++ show (i + 1)
Nothing -> formatAddr tx.src
prelude =
(if time > 0 || blocks > 0 then " _delay(" ++ show time ++ ", " ++ show blocks ++ ");\n" else "") ++
" _setUpActor(" ++ senderName ++ ");"
call = " Target." ++ unpack name ++ "(" ++ foundryArgs (map abiValueToString args) ++ ");"
in Just $ object ["prelude" .= prelude, "call" .= call]
_ -> Nothing
-- | Format arguments for a Solidity call.
foundryArgs :: [String] -> String
foundryArgs [] = ""
foundryArgs [x] = x
foundryArgs (x:xs) = x ++ ", " ++ foundryArgs xs
-- | Convert an AbiValue to its string representation for Solidity.
abiValueToString :: AbiValue -> String
abiValueToString (AbiUInt _ w) = show w
abiValueToString (AbiInt _ w) = show w
abiValueToString (AbiAddress a) = "address(0x" ++ showHex (fromIntegral a :: W256) "" ++ ")"
abiValueToString (AbiBool b) = if b then "true" else "false"
abiValueToString (AbiBytes _ bs) = "hex\"" ++ unpack (decodeUtf8 bs) ++ "\""
abiValueToString (AbiString s) = show s
abiValueToString (AbiTuple vs) = "(" ++ foundryArgs (map abiValueToString (V.toList vs)) ++ ")"
abiValueToString _ = ""
+184 -27
View File
@@ -1,4 +1,5 @@
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE TemplateHaskell #-}
module Echidna.Output.Source where
@@ -6,10 +7,13 @@ import Prelude hiding (writeFile)
import Control.Monad (unless)
import Data.ByteString qualified as BS
import Text.Mustache (substituteValue, toMustache)
import Text.Mustache.Compile (embedTemplate)
import Text.Mustache.Types (Template, Value(..))
import Data.Foldable
import Data.List (nub, sort)
import Data.List.NonEmpty qualified as NE
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Maybe (fromMaybe, mapMaybe, catMaybes)
import Data.Map (Map)
import Data.Map qualified as Map
import Data.Sequence qualified as Seq
@@ -18,11 +22,13 @@ import Data.Text (Text, pack)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf8)
import Data.Text.IO (writeFile)
import Data.Time (getCurrentTime, formatTime, defaultTimeLocale)
import Data.Vector qualified as V
import Data.Vector.Unboxed qualified as VU
import HTMLEntities.Text qualified as HTML
import System.Directory (createDirectoryIfMissing)
import System.FilePath ((</>))
import System.FilePath ((</>), splitDirectories, joinPath, takeDirectory)
import System.FilePath.Glob qualified as Glob
import Text.Printf (printf)
import EVM.Dapp (srcMapCodePos, DappInfo(..))
@@ -34,6 +40,19 @@ import Echidna.Types.Coverage (OpIx, unpackTxResults, FrozenCoverageMap, Coverag
import Echidna.Types.Tx (TxResult(..))
import Echidna.SourceAnalysis.Slither (AssertLocation(..), assertLocationList, SlitherInfo(..))
-- | Embedded template with partials for coverage reports
coverageTemplate :: Template
coverageTemplate = $(embedTemplate ["lib/Echidna/Output/assets"] "coverage.mustache")
-- | Filter files based on exclude patterns, using relative paths from common prefix
filterExcludedFiles :: [Text] -> FilePath -> [(FilePath, V.Vector Text)] -> [(FilePath, V.Vector Text)]
filterExcludedFiles excludePatterns commonPrefix allFiles =
let patterns = map (Glob.compile . T.unpack) excludePatterns
isExcluded filePath =
let relativePath = makeRelativePath commonPrefix filePath
in any (`Glob.match` relativePath) patterns
in filter (not . isExcluded . fst) allFiles
saveCoverages
:: Env
-> Int
@@ -43,8 +62,10 @@ saveCoverages
-> IO ()
saveCoverages env seed d sc cs = do
let fileTypes = env.cfg.campaignConf.coverageFormats
coverageExcludes = env.cfg.campaignConf.coverageExcludes
projectName = env.cfg.projectName
coverage <- mergeCoverageMaps env.dapp env.coverageRefInit env.coverageRefRuntime
mapM_ (\ty -> saveCoverage ty seed d sc cs coverage) fileTypes
mapM_ (\ty -> saveCoverage ty seed d sc cs coverage projectName coverageExcludes) fileTypes
saveCoverage
:: CoverageFileType
@@ -53,11 +74,15 @@ saveCoverage
-> SourceCache
-> [SolcContract]
-> FrozenCoverageMap
-> Maybe Text
-> [Text]
-> IO ()
saveCoverage fileType seed d sc cs covMap = do
saveCoverage fileType seed d sc cs covMap projectName excludePatterns = do
let extension = coverageFileExtension fileType
fn = d </> "covered." <> show seed <> extension
cc = ppCoveredCode fileType sc cs covMap
currentTime <- getCurrentTime
let timestamp = T.pack $ formatTime defaultTimeLocale "%B %d, %Y at %H:%M:%S UTC" currentTime
cc = ppCoveredCode fileType sc cs covMap projectName timestamp excludePatterns
createDirectoryIfMissing True d
writeFile fn cc
@@ -67,42 +92,40 @@ coverageFileExtension Html = ".html"
coverageFileExtension Txt = ".txt"
-- | Pretty-print the covered code
ppCoveredCode :: CoverageFileType -> SourceCache -> [SolcContract] -> FrozenCoverageMap -> Text
ppCoveredCode fileType sc cs s | null s = "Coverage map is empty"
| otherwise =
let
-- List of covered lines during the fuzzing campaign
covLines = srcMapCov sc s cs
-- Collect all the possible lines from all the files
allFiles = (\(path, src) -> (path, V.fromList (decodeUtf8 <$> BS.split 0xa src))) <$> Map.elems sc.files
-- Excludes lines such as comments or blanks
runtimeLinesMap = buildRuntimeLinesMap sc cs
ppCoveredCode :: CoverageFileType -> SourceCache -> [SolcContract] -> FrozenCoverageMap -> Maybe Text -> Text -> [Text] -> Text
ppCoveredCode fileType sc cs s projectName timestamp excludePatterns
| null s = "Coverage map is empty"
| Html <- fileType = htmlTemplate filteredFiles runtimeLinesMap covLines projectName timestamp commonPrefix
| otherwise = let
-- Pretty print individual file coverage
ppFile (srcPath, srcLines) =
let runtimeLines = fromMaybe mempty $ Map.lookup srcPath runtimeLinesMap
marked = markLines fileType srcLines runtimeLines (fromMaybe Map.empty (Map.lookup srcPath covLines))
in T.unlines (changeFileName srcPath : changeFileLines (V.toList marked))
-- Text to add to top of the file
topHeader = case fileType of
Lcov -> "TN:\n"
Html -> "<style> code { white-space: pre-wrap; display: block; background-color: #eee; }" <>
".e { background-color: #afa; }" <> -- executed
".r { background-color: #ffa; }" <> -- reverted
".u { background-color: #faa; }" <> -- unexecuted
".n { background-color: #eee; }" <> -- neutral
"</style>"
Txt -> ""
-- ^ Text to add to top of the file
-- Alter file name
changeFileName (T.pack -> fn) = case fileType of
Lcov -> "SF:" <> fn
Html -> "<b>" <> HTML.text fn <> "</b>"
Txt -> fn
-- ^ Alter file name, in the case of html turning it into bold text
-- Alter file contents
changeFileLines ls = case fileType of
Lcov -> ls ++ ["end_of_record"]
Html -> "<code>" : ls ++ ["", "</code>","<br />"]
Txt -> ls
-- ^ Alter file contents, in the case of html encasing it in <code> and adding a line break
in topHeader <> T.unlines (map ppFile allFiles)
in topHeader <> T.unlines (map ppFile filteredFiles)
where
-- List of covered lines during the fuzzing campaign
covLines = srcMapCov sc s cs
-- Collect all the possible lines from all the files
allFiles = (\(path, src) -> (path, V.fromList (decodeUtf8 <$> BS.split 0xa src))) <$> Map.elems sc.files
-- Find common path prefix for filtering
commonPrefix = findCommonPathPrefix (map fst allFiles)
-- Filter out excluded files using relative paths
filteredFiles = filterExcludedFiles excludePatterns commonPrefix allFiles
-- Excludes lines such as comments or blanks
runtimeLinesMap = buildRuntimeLinesMap sc cs
-- | Mark one particular line, from a list of lines, keeping the order of them
markLines :: CoverageFileType -> V.Vector Text -> S.Set Int -> Map Int [TxResult] -> V.Vector Text
@@ -217,3 +240,137 @@ checkAssertionReached covLines assert =
warnAssertNotReached =
putStrLn $ "WARNING: assertion at file: " ++ assert.filenameRelative
++ " starting at line: " ++ show (NE.head assert.assertLines) ++ " was never reached"
-- | Find the common path prefix among all file paths
findCommonPathPrefix :: [FilePath] -> FilePath
findCommonPathPrefix [] = ""
findCommonPathPrefix [path] = takeDirectory path
findCommonPathPrefix paths =
let pathComponents = map splitDirectories paths
commonComponents = foldl1 commonPrefix pathComponents
in joinPath commonComponents
where
commonPrefix [] _ = []
commonPrefix _ [] = []
commonPrefix (x:xs) (y:ys)
| x == y = x : commonPrefix xs ys
| otherwise = []
-- | Convert absolute path to relative path given a base directory
makeRelativePath :: FilePath -> FilePath -> FilePath
makeRelativePath basePath filePath =
let baseComponents = splitDirectories basePath
fileComponents = splitDirectories filePath
in maybe filePath joinPath (stripPrefix baseComponents fileComponents)
where
stripPrefix [] ys = Just ys
stripPrefix _ [] = Nothing
stripPrefix (x:xs) (y:ys)
| x == y = stripPrefix xs ys
| otherwise = Nothing
-- | Generate modern HTML coverage report using mustache template
htmlTemplate :: [(FilePath, V.Vector Text)] -> Map FilePath (S.Set Int) -> Map FilePath (Map Int [TxResult]) -> Maybe Text -> Text -> FilePath -> Text
htmlTemplate allFiles runtimeLinesMap covLines projectName timestamp commonPrefix =
substituteValue coverageTemplate $ buildTemplateContext allFiles runtimeLinesMap covLines projectName timestamp commonPrefix
-- | Build the context object for the mustache template
buildTemplateContext :: [(FilePath, V.Vector Text)] -> Map FilePath (S.Set Int) -> Map FilePath (Map Int [TxResult]) -> Maybe Text -> Text -> FilePath -> Value
buildTemplateContext allFiles runtimeLinesMap covLines projectName timestamp commonPrefix =
let
totalFiles = length allFiles
(totalLines, totalCoveredLines, totalActiveLines) = calculateTotalStats allFiles runtimeLinesMap covLines
coveragePercentage = if totalActiveLines == 0 then 0 else (totalCoveredLines * 100) `div` totalActiveLines
-- Build title with optional project name
title = case projectName of
Just name -> "Echidna Coverage Report - " <> name
Nothing -> "Echidna Coverage Report"
filesData = map (buildFileContext runtimeLinesMap covLines commonPrefix) allFiles
in toMustache $ (Map.fromList :: [(Text, Value)] -> Map Text Value)
[ ("title", toMustache title)
, ("totalFiles", toMustache $ T.pack $ show totalFiles)
, ("totalLines", toMustache $ T.pack $ show totalLines)
, ("totalCoveredLines", toMustache $ T.pack $ show totalCoveredLines)
, ("totalActiveLines", toMustache $ T.pack $ show totalActiveLines)
, ("coveragePercentage", toMustache $ T.pack $ printf "%.1f" (fromIntegral coveragePercentage :: Double))
, ("coverageColor", toMustache $ getCoverageColorHsl coveragePercentage)
, ("timestamp", toMustache timestamp)
, ("files", toMustache filesData)
]
-- | Build context for a single file
buildFileContext :: Map FilePath (S.Set Int) -> Map FilePath (Map Int [TxResult]) -> FilePath -> (FilePath, V.Vector Text) -> Value
buildFileContext runtimeLinesMap covLines commonPrefix (srcPath, srcLines) =
let
runtimeLines = fromMaybe mempty $ Map.lookup srcPath runtimeLinesMap
covered = fromMaybe Map.empty (Map.lookup srcPath covLines)
activeLines = S.size runtimeLines
coveredLines = length $ filter (`Map.member` covered) (S.toList runtimeLines)
coveragePercentage = if activeLines == 0 then 0 else (coveredLines * 100) `div` activeLines
-- Use relative path for display
displayPath = makeRelativePath commonPrefix srcPath
fileId = T.pack $ map (\c -> if c `elem` ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" :: String) then c else '_') displayPath
linesData = V.toList $ V.imap (buildLineContext runtimeLines covered) srcLines
in toMustache $ (Map.fromList :: [(Text, Value)] -> Map Text Value)
[ ("fileId", toMustache fileId)
, ("filePath", toMustache $ T.pack displayPath)
, ("activeLines", toMustache $ T.pack $ show activeLines)
, ("coveredLines", toMustache $ T.pack $ show coveredLines)
, ("coveragePercentage", toMustache $ T.pack $ printf "%.1f" (fromIntegral coveragePercentage :: Double))
, ("coverageColor", toMustache $ getCoverageColorHsl coveragePercentage)
, ("coverageColorAlpha", toMustache $ getCoverageColorAlpha coveragePercentage)
, ("lines", toMustache linesData)
]
-- | Build context for a single line of code
buildLineContext :: S.Set Int -> Map Int [TxResult] -> Int -> Text -> Value
buildLineContext runtimeLines covered lineIndex codeLine =
let
lineNum = lineIndex + 1
results = fromMaybe [] (Map.lookup lineNum covered)
isActive = lineNum `S.member` runtimeLines
isCovered = not (null results)
rowClass
| not isActive = Nothing
| isCovered = Just ("row-line-covered" :: Text)
| otherwise = Just ("row-line-uncovered" :: Text)
in toMustache $ (Map.fromList :: [(Text, Value)] -> Map Text Value) $ catMaybes
[ Just ("lineNumber", toMustache $ T.pack $ show lineNum)
, Just ("sourceCode", toMustache codeLine)
, fmap (\v -> ("rowClass", toMustache v)) rowClass
]
-- | Calculate total statistics across all files
calculateTotalStats :: [(FilePath, V.Vector Text)] -> Map FilePath (S.Set Int) -> Map FilePath (Map Int [TxResult]) -> (Int, Int, Int)
calculateTotalStats allFiles runtimeLinesMap covLines =
let
fileStats (srcPath, srcLines) =
let runtimeLines = fromMaybe mempty $ Map.lookup srcPath runtimeLinesMap
covered = fromMaybe Map.empty (Map.lookup srcPath covLines)
fileTotalLines = V.length srcLines
activeLines = S.size runtimeLines
coveredLines = length $ filter (`Map.member` covered) (S.toList runtimeLines)
in (fileTotalLines, coveredLines, activeLines)
allStats = map fileStats allFiles
totalLines = sum $ map (\(t,_,_) -> t) allStats
totalCoveredLines = sum $ map (\(_,c,_) -> c) allStats
totalActiveLines = sum $ map (\(_,_,a) -> a) allStats
in (totalLines, totalCoveredLines, totalActiveLines)
-- | Get HSL color based on coverage percentage
getCoverageColorHsl :: Int -> Text
getCoverageColorHsl percentage
| percentage < 50 = "hsl(" <> T.pack (show (percentage * 12 `div` 10)) <> ", 90%, 50%)"
| otherwise = "hsl(" <> T.pack (show (60 + ((percentage - 50) * 12 `div` 10))) <> ", 90%, 45%)"
-- | Get HSL color with alpha based on coverage percentage
getCoverageColorAlpha :: Int -> Text
getCoverageColorAlpha percentage
| percentage < 50 = "hsla(" <> T.pack (show (percentage * 12 `div` 10)) <> ", 90%, 50%, 0.15)"
| otherwise = "hsla(" <> T.pack (show (60 + ((percentage - 50) * 12 `div` 10))) <> ", 90%, 45%, 0.15)"
+119
View File
@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{title}}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
<style>
{{>styles.mustache}}
</style>
</head>
<body>
<div class="app-container">
<!-- Header -->
<header>
<div class="header-content">
<span class="logo-text">{{title}}</span>
</div>
</header>
<!-- Main content -->
<div class="split-panel" id="file-split-panel">
<!-- File Explorer -->
<div id="file-explorer">
<div class="file-explorer-header">
<h3 class="file-explorer-title">Files</h3>
</div>
<div class="file-search">
<input type="text" id="file-search-input" placeholder="Search files..." />
<button id="file-search-clear" title="Clear search">×</button>
</div>
<div class="file-explorer-content">
<ul id="file-tree-root" class="file-tree"></ul>
<div id="search-no-results" class="search-no-results" style="display: none;">No matching files found</div>
</div>
<div id="file-explorer-resizer"></div>
</div>
<!-- Main panel -->
<div id="main-view-panel">
<!-- Overview -->
<div class="stats-container">
<div class="stat-card">
<div class="stat-title">Files</div>
<div class="stat-value">{{totalFiles}}</div>
</div>
<div class="stat-card">
<div class="stat-title">Total Lines</div>
<div class="stat-value">{{totalLines}}</div>
</div>
<div class="stat-card">
<div class="stat-title">Coverage</div>
<div class="stat-value">{{coveragePercentage}}%</div>
<div class="progress-container">
<div class="progress-bar" style="width: {{coveragePercentage}}%; background-color: {{coverageColor}}"></div>
</div>
<div style="margin-top: 0.5rem">
<span class="stat-badge">{{totalCoveredLines}} / {{totalActiveLines}} lines</span>
</div>
</div>
<div class="stat-card">
<div class="stat-title">Actions</div>
<div class="button-group">
<button class="btn btn-secondary btn-sm" onclick="setAllSourceFilesCollapsed(false)">Expand All</button>
<button class="btn btn-secondary btn-sm" onclick="setAllSourceFilesCollapsed(true)">Collapse All</button>
</div>
<div class="button-group" style="margin-top: 0.5rem">
<button class="btn btn-secondary btn-sm" onclick="setEmptySourceFilesCollapsed(false)">Expand Empty</button>
<button class="btn btn-secondary btn-sm" onclick="setEmptySourceFilesCollapsed(true)">Collapse Empty</button>
</div>
</div>
</div>
<!-- Individual file coverage -->
{{#files}}
<div class="source-file" id="{{fileId}}" data-file-path="{{filePath}}" data-lines-active="{{activeLines}}" data-lines-covered="{{coveredLines}}">
<div class="source-file-header">
<div class="file-info">
<span class="coverage-badge" style="background-color: {{coverageColorAlpha}}; color: {{coverageColor}}">
{{coveragePercentage}}%
</span>
<span class="file-name">{{filePath}}</span>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="chevron-icon">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<div class="source-file-content">
<div class="source-file-stats">
<span>Lines covered: <strong>{{coveredLines}} / {{activeLines}}</strong> ({{coveragePercentage}}%)</span>
</div>
<div class="code-container">
<table class="code-coverage-table">
{{#lines}}
<tr{{#rowClass}} class="{{rowClass}}"{{/rowClass}}>
<td class="row-line-number">{{lineNumber}}</td>
<td class="row-source"><pre>{{sourceCode}}</pre></td>
</tr>
{{/lines}}
</table>
</div>
</div>
</div>
{{/files}}
</div>
</div>
<!-- Footer -->
<footer>
<div>Report generated by <a href="https://github.com/crytic/echidna" target="_blank">Echidna</a> on {{timestamp}}</div>
</footer>
</div>
<script>
{{>scripts.mustache}}
</script>
</body>
</html>
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
contract {{testName}} is Test {
{{#actors}}
address constant {{name}} = {{address}};
{{/actors}}
// TODO: Replace with your actual contract instance
{{contractName}} Target;
function setUp() public {
// TODO: Initialize your contract here
Target = new {{contractName}}();
}
function test_replay() public {
{{#reproducer}}
{{{prelude}}}
{{{call}}}
{{/reproducer}}
}
function _setUpActor(address actor) internal {
vm.startPrank(actor);
// Add any additional actor setup here if needed
}
function _delay(uint256 timeInSeconds, uint256 numBlocks) internal {
vm.warp(block.timestamp + timeInSeconds);
vm.roll(block.number + numBlocks);
}
}
+395
View File
@@ -0,0 +1,395 @@
// Initialize the application when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
initSourceFileExpansion();
initializeFileTree();
initResizer();
});
// Initialize source file expansion behavior
function initSourceFileExpansion() {
document.querySelectorAll('.source-file').forEach(file => {
const header = file.querySelector('.source-file-header');
header.addEventListener('click', () => {
file.classList.toggle('expanded');
const chevron = header.querySelector('.chevron-icon');
chevron.style.transform = file.classList.contains('expanded') ? 'rotate(180deg)' : 'rotate(0)';
});
});
}
// Initialize the resizer functionality
function initResizer() {
const fileExplorer = document.getElementById('file-explorer');
const resizer = document.getElementById('file-explorer-resizer');
let startX, startWidth;
// Handle mousedown on the resizer
resizer.addEventListener('mousedown', e => {
startX = e.clientX;
startWidth = parseInt(window.getComputedStyle(fileExplorer).width, 10);
// Add event listeners for resizing
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// Prevent text selection during resize
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
});
// Handle mousemove for resizing
function handleMouseMove(e) {
const newWidth = startWidth + (e.clientX - startX);
// Apply width constraints
if (newWidth >= 200 && newWidth <= 500) {
fileExplorer.style.width = newWidth + 'px';
fileExplorer.style.flex = `0 0 ${newWidth}px`;
}
}
// Handle mouseup to end resizing
function handleMouseUp() {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = '';
document.body.style.cursor = '';
}
}
// Helper function to get color based on coverage percentage
function getCoverageColor(percentage) {
return percentage < 50
? `hsl(${percentage * 1.2}, 90%, 50%)`
: `hsl(${60 + (percentage - 50) * 1.2}, 90%, 45%)`;
}
// Helper function for transparent background with coverage color
function getCoverageColorAlpha(percentage) {
return percentage < 50
? `hsla(${percentage * 1.2}, 90%, 50%, 0.15)`
: `hsla(${60 + (percentage - 50) * 1.2}, 90%, 45%, 0.15)`;
}
// Build file tree structure from file paths
function buildFileTree() {
const root = { name: '', type: 'directory', children: {}, path: '' };
const sourceFiles = document.getElementsByClassName("source-file");
// First pass: build the tree structure
for (let i = 0; i < sourceFiles.length; i++) {
const filePath = sourceFiles[i].dataset.filePath;
const pathParts = filePath.split(/[\/\\]+/);
let current = root;
let currentPath = '';
for (let j = 0; j < pathParts.length; j++) {
const part = pathParts[j];
if (part === '') continue;
currentPath += (currentPath ? '/' : '') + part;
if (j === pathParts.length - 1) {
// This is a file
current.children[part] = {
name: part,
type: 'file',
path: filePath,
element: sourceFiles[i],
coveredLines: parseInt(sourceFiles[i].dataset.linesCovered),
activeLines: parseInt(sourceFiles[i].dataset.linesActive)
};
} else {
// This is a directory
if (!current.children[part]) {
current.children[part] = {
name: part,
type: 'directory',
children: {},
path: currentPath
};
}
current = current.children[part];
}
}
}
// Second pass: calculate directory coverage
calculateDirectoryCoverage(root);
return root;
}
// Calculate coverage for directories by aggregating file coverage
function calculateDirectoryCoverage(node) {
if (node.type === 'file') {
return {
coveredLines: node.coveredLines,
activeLines: node.activeLines
};
}
let totalCovered = 0;
let totalActive = 0;
for (const childName in node.children) {
const child = node.children[childName];
const { coveredLines, activeLines } = calculateDirectoryCoverage(child);
totalCovered += coveredLines;
totalActive += activeLines;
}
node.coveredLines = totalCovered;
node.activeLines = totalActive;
return {
coveredLines: totalCovered,
activeLines: totalActive
};
}
// Render the file tree to the DOM
function renderFileTree(node, parentElement, isRoot = false) {
const list = isRoot ? parentElement : document.createElement('ul');
// Sort children: directories first, then files, both alphabetically
const sortedChildren = Object.values(node.children).sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const child of sortedChildren) {
const li = document.createElement('li');
li.className = child.type;
// Create item container
const itemDiv = document.createElement('div');
itemDiv.className = 'file-item';
// Create toggle button for directories
const toggle = document.createElement('span');
toggle.className = 'file-toggle';
itemDiv.appendChild(toggle);
// Create file/folder icon
const icon = document.createElement('span');
icon.className = 'file-icon';
itemDiv.appendChild(icon);
// Create name span
const nameSpan = document.createElement('span');
nameSpan.className = 'file-name';
nameSpan.textContent = child.name;
itemDiv.appendChild(nameSpan);
// Add coverage indicator
if (child.activeLines > 0) {
const coverage = document.createElement('span');
coverage.className = 'file-coverage';
const percentage = Math.round((child.coveredLines / child.activeLines) * 100);
coverage.style.backgroundColor = getCoverageColor(percentage);
coverage.title = `${child.coveredLines}/${child.activeLines} lines covered (${percentage}%)`;
itemDiv.appendChild(coverage);
}
li.appendChild(itemDiv);
// Handle click events
if (child.type === 'directory') {
toggle.addEventListener('click', e => {
e.stopPropagation();
li.classList.toggle('expanded');
});
itemDiv.addEventListener('click', () => {
li.classList.toggle('expanded');
});
// Recursively render children
renderFileTree(child, li);
} else {
// Add click handler for files
itemDiv.addEventListener('click', () => {
// Deactivate all other items
document.querySelectorAll('.file-item.active').forEach(el => {
el.classList.remove('active');
});
// Activate this item
itemDiv.classList.add('active');
// Navigate to the file
const fileElement = child.element;
fileElement.scrollIntoView({ behavior: 'smooth' });
// Expand the file if it's collapsed
if (!fileElement.classList.contains('expanded')) {
fileElement.querySelector('.source-file-header').click();
}
});
}
list.appendChild(li);
}
if (!isRoot) {
parentElement.appendChild(list);
}
}
// Initialize the file tree
function initializeFileTree() {
const fileTreeRoot = document.getElementById('file-tree-root');
const fileTree = buildFileTree();
renderFileTree(fileTree, fileTreeRoot, true);
// Expand the first level directories by default
document.querySelectorAll('#file-tree-root > li.directory').forEach(dir => {
dir.classList.add('expanded');
});
// Initialize search functionality
initializeFileSearch();
}
// Initialize file search functionality
function initializeFileSearch() {
const searchInput = document.getElementById('file-search-input');
const clearButton = document.getElementById('file-search-clear');
const noResultsMessage = document.getElementById('search-no-results');
// Debounce function to limit how often the search is performed during typing
function debounce(func, delay) {
let timeout;
return function() {
const context = this;
const args = arguments;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), delay);
};
}
// Function to highlight text that matches the search term
function highlightText(element, searchTerm) {
const text = element.textContent;
if (!searchTerm) {
// Clear any existing highlights
element.textContent = text;
return false;
}
const lowerText = text.toLowerCase();
const lowerSearchTerm = searchTerm.toLowerCase();
if (lowerText.includes(lowerSearchTerm)) {
const startIndex = lowerText.indexOf(lowerSearchTerm);
const endIndex = startIndex + lowerSearchTerm.length;
const beforeMatch = text.substring(0, startIndex);
const match = text.substring(startIndex, endIndex);
const afterMatch = text.substring(endIndex);
element.innerHTML = beforeMatch + '<span class="search-highlight">' + match + '</span>' + afterMatch;
return true;
}
element.textContent = text;
return false;
}
// Function to filter the file tree based on search term
function filterFileTree(searchTerm) {
const fileTreeItems = document.querySelectorAll('.file-tree li');
let hasVisibleItems = false;
if (searchTerm === '') {
// If search is cleared, show everything and reset expansion state
fileTreeItems.forEach(item => {
const nameElement = item.querySelector('.file-name');
highlightText(nameElement, ''); // Clear highlights
item.style.display = '';
// Only expand first level by default
if (item.classList.contains('directory')) {
// Check if this is a direct child of the root file tree
if (item.parentElement.id === 'file-tree-root') {
item.classList.add('expanded');
} else {
item.classList.remove('expanded');
}
}
});
} else {
// Two-pass filtering for search
// First pass: hide everything and find matches
const matchingItems = [];
fileTreeItems.forEach(item => {
const nameElement = item.querySelector('.file-name');
const isMatch = highlightText(nameElement, searchTerm);
item.style.display = 'none'; // Hide everything initially
if (isMatch) {
matchingItems.push(item);
}
});
// Second pass: show matching items and all their ancestor directories
matchingItems.forEach(item => {
hasVisibleItems = true;
// Show the matching item
item.style.display = '';
// Show and expand all parent directories
let parent = item.parentElement.closest('li.directory');
while (parent) {
parent.style.display = '';
parent.classList.add('expanded');
parent = parent.parentElement.closest('li.directory');
}
});
}
// Show/hide the "no results" message
noResultsMessage.style.display = searchTerm && !hasVisibleItems ? 'block' : 'none';
}
// Debounced search function to avoid excessive filtering while typing
const debouncedSearch = debounce(filterFileTree, 200);
// Search input event
searchInput.addEventListener('input', function() {
debouncedSearch(this.value.trim());
});
// Clear button event
clearButton.addEventListener('click', function() {
searchInput.value = '';
filterFileTree('');
searchInput.focus();
});
}
// Button click handler for expanding/collapsing all files
function setAllSourceFilesCollapsed(collapsed) {
document.querySelectorAll('.source-file').forEach(file => {
if ((collapsed && file.classList.contains('expanded')) ||
(!collapsed && !file.classList.contains('expanded'))) {
file.querySelector('.source-file-header').click();
}
});
}
// Button click handler for expanding/collapsing empty files
function setEmptySourceFilesCollapsed(collapsed) {
document.querySelectorAll('.source-file').forEach(file => {
if (parseInt(file.dataset.linesActive) <= 0) {
if ((collapsed && file.classList.contains('expanded')) ||
(!collapsed && !file.classList.contains('expanded'))) {
file.querySelector('.source-file-header').click();
}
}
});
}
+525
View File
@@ -0,0 +1,525 @@
:root {
--primary: #6366f1;
--primary-hover: #4f46e5;
--primary-light: rgba(99,102,241,0.1);
--success: #22c55e;
--warning: #eab308;
--danger: #ef4444;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-400: #9ca3af;
--gray-500: #6b7280;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--surface: #fff;
--text: var(--gray-800);
--text-light: var(--gray-500);
--radius: 0.375rem;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow: 0 1px 3px rgba(0,0,0,0.1);
--success-rgb: 34,197,94;
--warning-rgb: 234,179,8;
--danger-rgb: 239,68,68;
}
/* Reset */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Roboto', -apple-system, sans-serif;
font-size: 0.875rem;
line-height: 1.5;
color: var(--text);
background: var(--gray-50);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
color: var(--primary-hover);
text-decoration: underline;
}
button {
cursor: pointer;
background: transparent;
border: none;
padding: 0;
font: inherit;
color: inherit;
outline: none;
}
/* Layout */
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
header {
background: var(--surface);
border-bottom: 1px solid var(--gray-200);
padding: 1rem 1.5rem;
z-index: 10;
box-shadow: var(--shadow-sm);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1400px;
margin: 0;
width: 100%;
}
.logo-text {
font-size: 1.5rem;
font-weight: 600;
color: var(--gray-800);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 2.25rem;
padding: 0 0.5rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--radius);
transition: all 0.2s;
border: 1px solid transparent;
}
.btn-secondary {
background: #B92B3D;
border-color: #B92B3D;
color: white;
}
.btn-secondary:hover {
background: #9e2434;
border-color: #9e2434;
}
.btn-sm {
height: 2rem;
padding: 0 0.5rem;
font-size: 0.75rem;
}
.button-group {
display: flex;
gap: 0.75rem;
width: 100%;
}
.button-group .btn {
flex: 1;
}
/* Split panel layout */
.split-panel {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
#file-split-panel {
display: flex;
flex-direction: row;
}
/* File explorer panel */
#file-explorer {
flex: 0 0 260px;
height: 100%;
overflow: auto;
background: var(--surface);
border-right: 1px solid var(--gray-200);
padding: 0;
position: relative;
min-width: 200px;
max-width: 500px;
display: flex;
flex-direction: column;
overflow-x: hidden; /* to not show horizontal scrollbar with resizer */
}
#file-explorer-resizer {
position: absolute;
top: 0;
right: -5px;
width: 10px;
height: 100%;
cursor: col-resize;
z-index: 10;
background: linear-gradient(to right, transparent, rgba(0,0,0,0.05), transparent);
}
#file-explorer-resizer:hover {
background: linear-gradient(to right, transparent, rgba(0,0,0,0.1), transparent);
}
.file-explorer-header {
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--gray-200);
}
.file-search {
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--gray-200);
display: flex;
align-items: center;
}
.file-search input {
flex: 1;
border: 1px solid var(--gray-300);
border-radius: var(--radius);
padding: 0.5rem;
font-size: 0.875rem;
outline: none;
}
.file-search input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary-light);
}
#file-search-clear {
margin-left: 0.5rem;
color: var(--gray-500);
font-size: 1.25rem;
line-height: 1;
padding: 0.25rem;
border-radius: var(--radius);
}
#file-search-clear:hover {
background: var(--gray-100);
}
.search-highlight {
background: var(--primary-light);
color: var(--primary);
font-weight: 500;
}
.search-no-results {
padding: 1rem;
font-style: italic;
color: var(--gray-500);
text-align: center;
}
.file-explorer-content {
overflow: auto;
flex: 1;
padding: 1rem;
}
.file-explorer-title {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
color: var(--gray-600);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Main view panel */
#main-view-panel {
flex: 1;
height: 100%;
overflow: auto;
padding: 1.5rem;
background: var(--gray-50);
}
/* Stats cards */
.stats-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--surface);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
}
.stat-title {
font-size: 0.875rem;
color: var(--text-light);
margin-bottom: 0.5rem;
}
.stat-value {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.stat-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
background: var(--primary-light);
color: var(--primary);
}
/* Progress bar */
.progress-container {
width: 100%;
height: 0.5rem;
background: var(--gray-200);
border-radius: 9999px;
overflow: hidden;
margin-top: 0.5rem;
}
.progress-bar {
height: 100%;
border-radius: 9999px;
transition: width 0.3s;
}
/* File tree */
.file-tree {
list-style: none;
padding-left: 0;
margin: 0;
font-size: 0.875rem;
}
.file-tree li {
margin: 0.25rem 0;
}
.file-tree ul {
list-style: none;
padding-left: 1.25rem;
margin: 0.25rem 0;
}
.file-item {
display: flex;
align-items: center;
cursor: pointer;
padding: 0.375rem 0.5rem;
border-radius: var(--radius);
transition: background-color 0.2s;
}
.file-item:hover {
background: var(--gray-100);
}
.file-item.active {
background: var(--primary-light);
color: var(--primary);
}
.file-toggle {
width: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: var(--gray-500);
}
.file-icon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 0.5rem;
color: var(--gray-500);
}
.file-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-coverage {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
margin-left: 0.5rem;
}
/* File tree icons */
.directory > .file-item > .file-icon:before { content: "📁"; }
.directory.expanded > .file-item > .file-icon:before { content: "📂"; }
.file > .file-item > .file-icon:before { content: "📄"; }
.directory > .file-item > .file-toggle:before { content: "▶"; }
.directory.expanded > .file-item > .file-toggle:before { content: "▼"; }
.file > .file-item > .file-toggle { visibility: hidden; }
.directory > ul { display: none; }
.directory.expanded > ul { display: block; }
/* Source files */
.source-file {
margin-bottom: 1.5rem;
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
background: var(--surface);
}
.source-file-header {
padding: 0.75rem 1rem;
background: #12181E;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.2s;
}
.source-file-header:hover {
background: #1e2832;
}
.source-file-header .file-info {
display: flex;
align-items: center;
gap: 1rem;
}
.source-file-header .file-name {
font-weight: 500;
}
.source-file-header .coverage-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.source-file-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.source-file.expanded .source-file-content {
max-height: none;
}
.source-file-stats {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--gray-200);
font-size: 0.875rem;
color: var(--text-light);
}
/* Code styling */
.code-container {
position: relative;
overflow: auto;
max-height: 600px;
}
.code-coverage-table {
border-collapse: collapse;
font-family: 'Roboto Mono', monospace;
font-size: 0.8125rem;
line-height: 1.5;
tab-size: 4;
width: 100%;
}
.code-coverage-table pre {
margin: 0;
font-family: inherit;
}
.row-line-number {
user-select: none;
text-align: right;
padding: 0 0.75rem;
width: 1%;
min-width: 2.5rem;
color: var(--gray-400);
background: var(--gray-100);
border-right: 1px solid var(--gray-200);
}
.row-reverted-status {
user-select: none;
text-align: center;
width: 1%;
min-width: 2.25rem;
max-width: fit-content;
padding: 0 0.25rem;
color: var(--gray-600);
background: var(--gray-100);
border-right: 1px solid var(--gray-200);
white-space: nowrap;
}
.row-source {
padding: 0 0.75rem;
}
.row-line-covered {
background: rgba(var(--success-rgb), 0.1);
}
.row-line-uncovered {
background: rgba(var(--danger-rgb), 0.1);
}
/* Footer */
footer {
background: var(--surface);
border-top: 1px solid var(--gray-200);
padding: 1rem;
text-align: center;
font-size: 0.875rem;
color: var(--gray-500);
}
/* Responsive */
@media (max-width: 768px) {
.stats-container { grid-template-columns: 1fr; }
#file-explorer { display: none; }
}
+3
View File
@@ -83,6 +83,9 @@ findSrcByMetadata contr dapp = find compareMetadata (snd <$> Map.elems dapp.solc
(RuntimeCode (ConcreteRuntimeCode c)) -> Just c
(RuntimeCode (SymbolicRuntimeCode c)) -> Just $ BS.pack $ mapMaybe maybeLitByteSimp $ V.toList c
findSrcForReal :: DappInfo -> Contract -> Maybe SolcContract
findSrcForReal dapp contr = findSrc contr dapp <|> findSrcByMetadata contr dapp
getBytecodeMetadata :: ByteString -> ByteString
getBytecodeMetadata bs =
let stripCandidates = flip BS.breakSubstring bs <$> knownBzzrPrefixes in
+6 -1
View File
@@ -22,6 +22,7 @@ import Data.Vector qualified as V
import EVM (ceilDiv, initialContract, loadContract, resetState)
import EVM.ABI (abiValueType)
import EVM.FeeSchedule (FeeSchedule(..))
import EVM.Transaction (setupTx)
import EVM.Types hiding (Env, Gas, VMOpts(timestamp, gasprice))
import Echidna.ABI
@@ -88,7 +89,7 @@ genTx world deployedContracts = do
fmap (forceAddr addr,) . snd <$> lookupUsingCodehash env.codehashMap c env.dapp sigMap
genDelay :: MonadRandom m => W256 -> Set W256 -> m W256
genDelay mv ds = do
genDelay mv ds =
join $ oftenUsually fromDict randValue
where randValue = fromIntegral <$> getRandomR (1 :: Integer, fromIntegral mv)
fromDict = (`mod` (mv + 1)) <$> rElem' ds
@@ -191,8 +192,12 @@ setupTx tx@Tx{call} = fromEVM $ do
modify' $ \vm ->
let intrinsicGas = txGasCost vm.block.schedule isCreate calldata
burned = min intrinsicGas vm.state.gas
-- This might not be 100% accurate (it does not revert the balance transfers)
-- but Echidna resets the vm on revert so it should not make a difference
reversionState = EVM.Transaction.setupTx vm.tx.origin vm.block.coinbase vm.tx.gasprice vm.tx.gaslimit vm.env.contracts
in vm & #state % #gas %!~ subtract burned
& #burned %!~ (+ burned)
& #tx % #txReversion !~ reversionState
where
incrementBalance = #env % #contracts % ix (LitAddr tx.dst) % #balance %= (\v -> Lit $ forceWord v + tx.value)
encode (n, vs) = abiCalldata (encodeSig (n, abiValueType <$> vs)) $ V.fromList vs
+2
View File
@@ -37,6 +37,8 @@ data CampaignConf = CampaignConf
-- ^ Mutation constants for fuzzing
, coverageFormats :: [CoverageFileType]
-- ^ List of file formats to save coverage reports
, coverageExcludes :: [Text]
-- ^ List of glob patterns for files/directories to exclude from coverage reports
, workers :: Maybe Word8
-- ^ Number of fuzzing workers
, serverPort :: Maybe Word16
+3
View File
@@ -53,11 +53,13 @@ library:
- exceptions
- extra
- filepath
- Glob
- hashable
- html-conduit
- html-entities
- http-conduit
- ListLike
- mustache
- optics
- optics-core
- process
@@ -95,6 +97,7 @@ executables:
- with-utf8
ghc-options:
- -threaded
- -rtsopts
- '"-with-rtsopts=-A64m -N"'
when:
- condition: (os(linux) || os(windows)) && flag(static)
+21 -1
View File
@@ -16,6 +16,7 @@ import Data.Maybe (fromMaybe, isJust)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Lazy qualified as TL
import Data.Time.Clock.System (getSystemTime, systemSeconds)
import Data.Version (showVersion)
import Data.Word (Word8, Word16, Word64)
@@ -38,13 +39,14 @@ import Echidna.Campaign (isSuccessful)
import Echidna.Config
import Echidna.Onchain qualified as Onchain
import Echidna.Output.Corpus
import Echidna.Output.Foundry
import Echidna.Output.Source
import Echidna.Solidity (compileContracts)
import Echidna.Test (reproduceTest, validateTestMode)
import Echidna.Types.Campaign
import Echidna.Types.Config
import Echidna.Types.Solidity
import Echidna.Types.Test (TestMode, EchidnaTest(..))
import Echidna.Types.Test (TestMode, EchidnaTest(..), TestType(..), TestState(..))
import Echidna.UI
import Echidna.UI.Report (ppFailWithTraces, ppTestName)
import Echidna.Utility (measureIO)
@@ -101,6 +103,24 @@ main = withUtf8 $ withCP65001 $ do
corpus <- readIORef env.corpusRef
saveTxs env (dir </> "coverage") (snd <$> Set.toList corpus)
let isLargeOrSolved Solved = True
isLargeOrSolved (Large _) = True
isLargeOrSolved _ = False
measureIO cfg.solConf.quiet "Saving foundry reproducers" $ do
let foundryDir = dir </> "foundry"
liftIO $ createDirectoryIfMissing True foundryDir
forM_ tests $ \test ->
case (test.testType, test.state) of
(AssertionTest{}, state) | isLargeOrSolved state ->
do
let
reproducerHash = (show . abs . hash) test.reproducer
fileName = foundryDir </> "Test." ++ reproducerHash <.> "sol"
content = foundryTest cliSelectedContract test
liftIO $ writeFile fileName (TL.unpack content)
_ -> pure ()
-- save coverage reports
let coverageDir = cfg.campaignConf.coverageDir <|> cfg.campaignConf.corpusDir
case coverageDir of
+2
View File
@@ -50,4 +50,6 @@ valuesTests = testGroup "Value extraction tests"
[ ("getItem passed", solved "getItem") ]
, testContract' "values/events.sol" Nothing Nothing (Just "values/events.yaml") False FuzzWorker
[ ("check passed", solved "check") ]
, testContract' "values/callback.sol" Nothing Nothing (Just "values/callback.yaml") False FuzzWorker
[ ("echidna_callback_executed passed", solved "echidna_callback_executed") ]
]
+4
View File
@@ -82,6 +82,10 @@ corpusDir: null
coverageDir: null
# list of file formats to save coverage reports in; default is all possible formats
coverageFormats: ["txt","html","lcov"]
# list of glob patterns for files to exclude from coverage reports
# patterns are matched against relative paths from the common project root
# examples: ["lib/**/*.sol", "test/mocks/**/*"]
coverageExcludes: []
# constants for corpus mutations (for experimentation only)
mutConsts: [1, 1, 1, 1]
# maximum value to send to payable functions
+23
View File
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Test {
bool internal callbackExecuted = false;
// The function we want to encode and pass to the callback
function transfer(address to, uint256 value) public {
// Ensure that only the contract itself can call this function
require(msg.sender == address(this), "transfer can only be called by the contract");
callbackExecuted = true;
}
// The function that receives the encoded calldata
function callback(bytes calldata data) public {
address(this).call(data);
}
// The Echidna property to test the callback
function echidna_callback_executed() public view returns (bool) {
return !callbackExecuted;
}
}
+3
View File
@@ -0,0 +1,3 @@
seqLen: 1
filterFunctions:
- Test.transfer