Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions lib/Echidna/Output/Foundry.hs
Original file line number Diff line number Diff line change
Expand Up @@ -27,28 +27,40 @@ 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 =
-- For property tests, psender is the address used to call the property function.
foundryTest :: Maybe Text -> Maybe Addr -> EchidnaTest -> TL.Text
foundryTest mContractName mPsender test =
case test.testType of
AssertionTest{} ->
let testData = createTestData mContractName test
let testData = createTestData mContractName Nothing test
in fromStrict $ substituteValue template (toMustache testData)
PropertyTest name _ ->
let testData = createTestData mContractName (Just (name, mPsender)) 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 =
-- When a property name and psender are provided, a final assertion is added
-- to call the property from psender and check it returns false.
createTestData :: Maybe Text -> Maybe (Text, Maybe Addr) -> EchidnaTest -> Value
createTestData mContractName mProperty test =
let
senders = nub $ map (.src) test.reproducer
actors = zipWith actorObject senders [1..]
repro = mapMaybe (foundryTx senders) test.reproducer
cName = fromMaybe "YourContract" mContractName
propAssertion = mProperty >>= \(name, mAddr) ->
let prank = case mAddr of
Just addr -> " vm.stopPrank();\n vm.prank(" ++ formatAddr addr ++ ");\n"
Nothing -> " vm.stopPrank();\n"
in Just $ prank ++ " assertFalse(Target." ++ unpack name ++ "());"
in
object
[ "testName" .= ("FoundryTest" :: Text)
, "contractName" .= cName
, "actors" .= actors
, "reproducer" .= repro
, "propertyAssertion" .= propAssertion
]

-- | Create a JSON object for an actor.
Expand Down
3 changes: 3 additions & 0 deletions lib/Echidna/Output/assets/foundry.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ contract {{testName}} is Test {
{{{prelude}}}
{{{call}}}
{{/reproducer}}
{{#propertyAssertion}}
{{{.}}}
{{/propertyAssertion}}
}

function _setUpActor(address actor) internal {
Expand Down
19 changes: 12 additions & 7 deletions src/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import Echidna.Test (validateTestMode)
import Echidna.Types.Campaign
import Echidna.Types.Config
import Echidna.Types.Solidity
import Echidna.Types.Test (TestMode, EchidnaTest(..), TestType(..), TestState(..))
import Echidna.Types.Test (TestMode, EchidnaTest(..), TestConf(..), TestType(..), TestState(..))
import Echidna.UI
import Echidna.Utility (measureIO)

Expand Down Expand Up @@ -93,16 +93,21 @@ main = withUtf8 $ withCP65001 $ do
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
TestConf{testSender} = cfg.testConf
psender = testSender 0
saveRepro mPsender test = do
let
reproducerHash = (show . abs . hash) test.reproducer
fileName = foundryDir </> "Test." ++ reproducerHash <.> "sol"
content = foundryTest cliSelectedContract test
content = foundryTest cliSelectedContract mPsender test
liftIO $ writeFile fileName (TL.unpack content)
liftIO $ createDirectoryIfMissing True foundryDir
forM_ tests $ \test ->
case (test.testType, test.state) of
(AssertionTest{}, state) | isLargeOrSolved state ->
saveRepro Nothing test
(PropertyTest{}, state) | isLargeOrSolved state ->
saveRepro (Just psender) test
_ -> pure ()


Expand Down
35 changes: 33 additions & 2 deletions src/test/Tests/FoundryTestGen.hs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ foundryTestGenTests = testGroup "Foundry test generation"
, testCase "correctly encodes bytes1" testBytes1Encoding
, testCase "fallback function syntax" testFallbackSyntax
, testCase "null bytes in arguments" testNullBytes
, testCase "property test generates assertFalse" testPropertyTestGen
, testGroup "Concrete execution (fuzzing)"
[ testForgeStd "solves assertTrue"
"foundry/FoundryAsserts.sol"
Expand Down Expand Up @@ -159,6 +160,9 @@ foundryTestGenTests = testGroup "Foundry test generation"
FuzzWorker
[ ("vm.assume should not be treated as test failure", passed "test_assume_filters")
]
, testContract "foundry/PropertyRepro.sol" (Just "foundry/PropertyRepro.yaml")
[ ("property test should be detected", solved "echidna_counter_is_zero")
]
]
, testGroup "Symbolic execution (SMT solving)"
[ testForgeStd "solves assertTrue"
Expand Down Expand Up @@ -287,7 +291,7 @@ testForgeCompiles tmpDirSuffix contractName testData outputFile = do
copyFile contractPath (tmpDir ++ "/src/" ++ contractFile)

-- Generate test and add contract import after forge-std import
let generated = TL.unpack $ foundryTest (Just (pack contractName)) testData
let generated = TL.unpack $ foundryTest (Just (pack contractName)) Nothing testData
forgeStdImport = pack "import \"forge-std/Test.sol\";"
contractImport = pack $ "import \"../src/" ++ contractFile ++ "\";"
testWithImport = unpack $ replace forgeStdImport
Expand Down Expand Up @@ -318,11 +322,38 @@ testBytes1Encoding = do
, delay = (0, 0)
}
test = mkMinimalTest { reproducer = [reproducerTx] }
generated = TL.unpack $ foundryTest (Just "FoundryTestTarget") test
generated = TL.unpack $ foundryTest (Just "FoundryTestTarget") Nothing test
if "hex\"92\"" `isInfixOf` generated
then pure ()
else assertFailure $ "bytes1 not correctly encoded: " ++ generated

-- | Test that property mode tests generate assertFalse with psender prank.
testPropertyTestGen :: IO ()
testPropertyTestGen = do
let
reproducerTx = Tx
{ call = SolCall ("inc", [])
, src = 0x10000
, dst = 0
, value = 0
, gas = 0
, gasprice = 0
, delay = (0, 0)
}
test = mkMinimalTest
{ testType = PropertyTest "echidna_counter_is_zero" 0
, reproducer = [reproducerTx]
}
generated = TL.unpack $ foundryTest (Just "PropertyRepro") (Just 0x10000) test
assertBool ("should contain assertFalse call, got: " ++ generated)
("assertFalse(Target.echidna_counter_is_zero())" `isInfixOf` generated)
assertBool ("should contain vm.prank for psender, got: " ++ generated)
("vm.prank(" `isInfixOf` generated)
assertBool ("should contain vm.stopPrank, got: " ++ generated)
("vm.stopPrank()" `isInfixOf` generated)
assertBool ("should contain inc() call, got: " ++ generated)
("Target.inc()" `isInfixOf` generated)

-- | Wrapper for testContractNamed that skips if solc < 0.8.13.
testForgeStd :: String -> FilePath -> Maybe String -> Maybe FilePath -> WorkerType -> [(String, (Env, WorkerState) -> IO Bool)] -> TestTree
testForgeStd name fp contract config workerType checks =
Expand Down
14 changes: 14 additions & 0 deletions tests/solidity/foundry/PropertyRepro.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract PropertyRepro {
uint256 public counter;

function inc() public {
counter++;
}

function echidna_counter_is_zero() public view returns (bool) {
return counter == 0;
}
}
3 changes: 3 additions & 0 deletions tests/solidity/foundry/PropertyRepro.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
testMode: property
seed: 1234
disableSlither: true
Loading