Skip to content

Commit 21c2e75

Browse files
Generate Foundry reproducers in property mode (#1546)
1 parent b2f55de commit 21c2e75

7 files changed

Lines changed: 86 additions & 16 deletions

File tree

lib/Echidna/Config.hs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import Data.Text (isPrefixOf)
1414
import Data.Yaml qualified as Y
1515

1616
import EVM.Solvers (Solver(..))
17-
import EVM.Types (VM(..), W256)
17+
import EVM.Types (Addr, VM(..), W256)
1818

1919
import Echidna.Mutator.Corpus (defaultMutationConsts)
2020
import Echidna.Test
@@ -82,7 +82,7 @@ instance FromJSON EConfigWithUsage where
8282
<*> getWord256 "maxValue" 100000000000000000000 -- 100 eth
8383

8484
testConfParser = do
85-
psender <- v ..:? "psender" ..!= 0x10000
85+
psender <- v ..:? "psender" ..!= defaultPsender
8686
fprefix <- v ..:? "prefix" ..!= "echidna_"
8787
let goal fname = if (fprefix <> "revert_") `isPrefixOf` fname then ResRevert else ResTrue
8888
classify fname vm = maybe ResOther classifyRes vm.result == goal fname
@@ -159,6 +159,10 @@ instance FromJSON EConfigWithUsage where
159159
Nothing -> pure Nothing
160160
_ -> fail "Unrecognized format type (should be text, json, or none)")
161161

162+
-- | Default address for the property test sender.
163+
defaultPsender :: Addr
164+
defaultPsender = 0x10000
165+
162166
-- | The default config used by Echidna (see the 'FromJSON' instance for values used).
163167
defaultConfig :: EConfig
164168
defaultConfig = either (error "Config parser got messed up :(") id $ Y.decodeEither' ""

lib/Echidna/Output/Foundry.hs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
module Echidna.Output.Foundry (foundryTest) where
66

77
import Data.Aeson (Value(..), object, (.=))
8+
import Data.Functor ((<&>))
89
import Data.List (elemIndex, nub)
910
import Data.Maybe (fromMaybe, mapMaybe)
1011
import Data.Text (Text, unpack)
@@ -27,28 +28,38 @@ template :: Template
2728
template = $(embedTemplate ["lib/Echidna/Output/assets"] "foundry.mustache")
2829

2930
-- | Generate a Foundry test from an EchidnaTest result.
30-
foundryTest :: Maybe Text -> EchidnaTest -> TL.Text
31-
foundryTest mContractName test =
31+
-- For property tests, psender is the address used to call the property function.
32+
foundryTest :: Maybe Text -> Addr -> EchidnaTest -> TL.Text
33+
foundryTest mContractName psender test =
3234
case test.testType of
3335
AssertionTest{} ->
34-
let testData = createTestData mContractName test
36+
let testData = createTestData mContractName Nothing test
37+
in fromStrict $ substituteValue template (toMustache testData)
38+
PropertyTest name _ ->
39+
let testData = createTestData mContractName (Just (name, psender)) test
3540
in fromStrict $ substituteValue template (toMustache testData)
3641
_ -> ""
3742

3843
-- | Create an Aeson Value from test data for the Mustache template.
39-
createTestData :: Maybe Text -> EchidnaTest -> Value
40-
createTestData mContractName test =
44+
-- When a property name and psender are provided, a final assertion is added
45+
-- to call the property from psender and check it returns false.
46+
createTestData :: Maybe Text -> Maybe (Text, Addr) -> EchidnaTest -> Value
47+
createTestData mContractName mProperty test =
4148
let
4249
senders = nub $ map (.src) test.reproducer
4350
actors = zipWith actorObject senders [1..]
4451
repro = mapMaybe (foundryTx senders) test.reproducer
4552
cName = fromMaybe "YourContract" mContractName
53+
propAssertion = mProperty <&> \(name, addr) ->
54+
" vm.stopPrank();\n vm.prank(" ++ formatAddr addr ++ ");\n"
55+
++ " assertFalse(Target." ++ unpack name ++ "());"
4656
in
4757
object
4858
[ "testName" .= ("FoundryTest" :: Text)
4959
, "contractName" .= cName
5060
, "actors" .= actors
5161
, "reproducer" .= repro
62+
, "propertyAssertion" .= propAssertion
5263
]
5364

5465
-- | Create a JSON object for an actor.

lib/Echidna/Output/assets/foundry.mustache

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ contract {{testName}} is Test {
2121
{{{prelude}}}
2222
{{{call}}}
2323
{{/reproducer}}
24+
{{#propertyAssertion}}
25+
{{{.}}}
26+
{{/propertyAssertion}}
2427
}
2528

2629
function _setUpActor(address actor) internal {

src/Main.hs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import Echidna.Test (validateTestMode)
4545
import Echidna.Types.Campaign
4646
import Echidna.Types.Config
4747
import Echidna.Types.Solidity
48-
import Echidna.Types.Test (TestMode, EchidnaTest(..), TestType(..), TestState(..))
48+
import Echidna.Types.Test (TestMode, EchidnaTest(..), TestConf(..), TestType(..), TestState(..))
4949
import Echidna.UI
5050
import Echidna.Utility (measureIO)
5151

@@ -93,16 +93,19 @@ main = withUtf8 $ withCP65001 $ do
9393
isLargeOrSolved _ = False
9494
measureIO cfg.solConf.quiet "Saving foundry reproducers" $ do
9595
let foundryDir = dir </> "foundry"
96-
liftIO $ createDirectoryIfMissing True foundryDir
97-
forM_ tests $ \test ->
98-
case (test.testType, test.state) of
99-
(AssertionTest{}, state) | isLargeOrSolved state ->
100-
do
96+
TestConf{testSender} = cfg.testConf
97+
psender = testSender 0
98+
saveRepro test = do
10199
let
102100
reproducerHash = (show . abs . hash) test.reproducer
103101
fileName = foundryDir </> "Test." ++ reproducerHash <.> "sol"
104-
content = foundryTest cliSelectedContract test
102+
content = foundryTest cliSelectedContract psender test
105103
liftIO $ writeFile fileName (TL.unpack content)
104+
liftIO $ createDirectoryIfMissing True foundryDir
105+
forM_ tests $ \test ->
106+
case (test.testType, test.state) of
107+
(AssertionTest{}, state) | isLargeOrSolved state -> saveRepro test
108+
(PropertyTest{}, state) | isLargeOrSolved state -> saveRepro test
106109
_ -> pure ()
107110

108111

src/test/Tests/FoundryTestGen.hs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import System.Process (readProcessWithExitCode)
1515
import Text.Read (readMaybe)
1616

1717
import Common (solved, passed, testContract, testContractNamed)
18+
import Echidna.Config (defaultPsender)
1819
import Echidna.Types.Config (Env)
1920
import Echidna.Types.Campaign (WorkerState)
2021
import EVM.ABI (AbiValue(..))
@@ -29,6 +30,7 @@ foundryTestGenTests = testGroup "Foundry test generation"
2930
, testCase "correctly encodes bytes1" testBytes1Encoding
3031
, testCase "fallback function syntax" testFallbackSyntax
3132
, testCase "null bytes in arguments" testNullBytes
33+
, testCase "property test generates assertFalse" testPropertyTestGen
3234
, testGroup "Concrete execution (fuzzing)"
3335
[ testForgeStd "solves assertTrue"
3436
"foundry/FoundryAsserts.sol"
@@ -159,6 +161,9 @@ foundryTestGenTests = testGroup "Foundry test generation"
159161
FuzzWorker
160162
[ ("vm.assume should not be treated as test failure", passed "test_assume_filters")
161163
]
164+
, testContract "foundry/PropertyRepro.sol" (Just "foundry/PropertyRepro.yaml")
165+
[ ("property test should be detected", solved "echidna_counter_is_zero")
166+
]
162167
]
163168
, testGroup "Symbolic execution (SMT solving)"
164169
[ testForgeStd "solves assertTrue"
@@ -287,7 +292,7 @@ testForgeCompiles tmpDirSuffix contractName testData outputFile = do
287292
copyFile contractPath (tmpDir ++ "/src/" ++ contractFile)
288293

289294
-- Generate test and add contract import after forge-std import
290-
let generated = TL.unpack $ foundryTest (Just (pack contractName)) testData
295+
let generated = TL.unpack $ foundryTest (Just (pack contractName)) defaultPsender testData
291296
forgeStdImport = pack "import \"forge-std/Test.sol\";"
292297
contractImport = pack $ "import \"../src/" ++ contractFile ++ "\";"
293298
testWithImport = unpack $ replace forgeStdImport
@@ -318,11 +323,38 @@ testBytes1Encoding = do
318323
, delay = (0, 0)
319324
}
320325
test = mkMinimalTest { reproducer = [reproducerTx] }
321-
generated = TL.unpack $ foundryTest (Just "FoundryTestTarget") test
326+
generated = TL.unpack $ foundryTest (Just "FoundryTestTarget") defaultPsender test
322327
if "hex\"92\"" `isInfixOf` generated
323328
then pure ()
324329
else assertFailure $ "bytes1 not correctly encoded: " ++ generated
325330

331+
-- | Test that property mode tests generate assertFalse with psender prank.
332+
testPropertyTestGen :: IO ()
333+
testPropertyTestGen = do
334+
let
335+
reproducerTx = Tx
336+
{ call = SolCall ("inc", [])
337+
, src = 0x10000
338+
, dst = 0
339+
, value = 0
340+
, gas = 0
341+
, gasprice = 0
342+
, delay = (0, 0)
343+
}
344+
test = mkMinimalTest
345+
{ testType = PropertyTest "echidna_counter_is_zero" 0
346+
, reproducer = [reproducerTx]
347+
}
348+
generated = TL.unpack $ foundryTest (Just "PropertyRepro") defaultPsender test
349+
assertBool ("should contain assertFalse call, got: " ++ generated)
350+
("assertFalse(Target.echidna_counter_is_zero())" `isInfixOf` generated)
351+
assertBool ("should contain vm.prank for psender, got: " ++ generated)
352+
("vm.prank(" `isInfixOf` generated)
353+
assertBool ("should contain vm.stopPrank, got: " ++ generated)
354+
("vm.stopPrank()" `isInfixOf` generated)
355+
assertBool ("should contain inc() call, got: " ++ generated)
356+
("Target.inc()" `isInfixOf` generated)
357+
326358
-- | Wrapper for testContractNamed that skips if solc < 0.8.13.
327359
testForgeStd :: String -> FilePath -> Maybe String -> Maybe FilePath -> WorkerType -> [(String, (Env, WorkerState) -> IO Bool)] -> TestTree
328360
testForgeStd name fp contract config workerType checks =
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.0;
3+
4+
contract PropertyRepro {
5+
uint256 public counter;
6+
7+
function inc() public {
8+
counter++;
9+
}
10+
11+
function echidna_counter_is_zero() public view returns (bool) {
12+
return counter == 0;
13+
}
14+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
testMode: property
2+
seed: 1234
3+
disableSlither: true

0 commit comments

Comments
 (0)