Skip to content

Commit 6a71f4f

Browse files
allow foundry unit test generation to handle assertion failure with events
1 parent 7cbb32f commit 6a71f4f

5 files changed

Lines changed: 119 additions & 8 deletions

File tree

lib/Echidna/Output/Foundry.hs

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

77
import Data.Aeson (Value(..), object, (.=))
88
import Data.Functor ((<&>))
9-
import Data.List (elemIndex, nub)
9+
import Data.List (elemIndex, isPrefixOf, nub)
1010
import Data.Maybe (fromMaybe, mapMaybe)
1111
import Data.Text (Text, unpack)
1212
import Data.Text.Lazy (fromStrict)
@@ -33,33 +33,67 @@ foundryTest :: Maybe Text -> Addr -> EchidnaTest -> TL.Text
3333
foundryTest mContractName psender test =
3434
case test.testType of
3535
AssertionTest{} ->
36-
let testData = createTestData mContractName Nothing test
36+
let testData = createTestData mContractName Nothing Nothing test
3737
in fromStrict $ substituteValue template (toMustache testData)
3838
PropertyTest name _ ->
39-
let testData = createTestData mContractName (Just (name, psender)) test
39+
let testData = createTestData mContractName (Just (name, psender)) Nothing test
40+
in fromStrict $ substituteValue template (toMustache testData)
41+
CallTest name _ | "AssertionFailed" `isPrefixOf` unpack name ->
42+
-- Echidna detects assertion failures via events named AssertionFailed
43+
-- with any argument types (see checkAssertionEvent in Echidna.Test).
44+
-- We check all overloads defined in crytic's fuzzlib (LibLog.sol):
45+
-- AssertionFailed()
46+
-- AssertionFailed(string)
47+
-- AssertionFailed(string,string)
48+
-- AssertionFailed(string,bytes)
49+
-- AssertionFailed(string,uint256)
50+
-- AssertionFailed(string,int256)
51+
-- AssertionFailed(string,address)
52+
-- AssertionFailed(string,bool)
53+
-- AssertionFailed(string,bytes32)
54+
let eventAssert = Just $
55+
" // Check that an AssertionFailed event was emitted\n"
56+
++ " Vm.Log[] memory entries = vm.getRecordedLogs();\n"
57+
++ " bool found = false;\n"
58+
++ " for (uint i = 0; i < entries.length; i++) {\n"
59+
++ " if (entries[i].topics.length > 0 && _isAssertionFailed(entries[i].topics[0])) {\n"
60+
++ " found = true;\n"
61+
++ " break;\n"
62+
++ " }\n"
63+
++ " }\n"
64+
++ " assertTrue(found, \"Expected AssertionFailed event\");"
65+
testData = createTestData mContractName Nothing eventAssert test
4066
in fromStrict $ substituteValue template (toMustache testData)
4167
_ -> ""
4268

4369
-- | Create an Aeson Value from test data for the Mustache template.
4470
-- When a property name and psender are provided, a final assertion is added
4571
-- 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 =
72+
-- When an event assertion is provided, vm.recordLogs() is added at the start
73+
-- and the event check is added at the end.
74+
createTestData :: Maybe Text -> Maybe (Text, Addr) -> Maybe String -> EchidnaTest -> Value
75+
createTestData mContractName mProperty mEventAssert test =
4876
let
4977
senders = nub $ map (.src) test.reproducer
5078
actors = zipWith actorObject senders [1..]
5179
repro = mapMaybe (foundryTx senders) test.reproducer
5280
cName = fromMaybe "YourContract" mContractName
53-
propAssertion = mProperty <&> \(name, addr) ->
54-
" vm.stopPrank();\n vm.prank(" ++ formatAddr addr ++ ");\n"
55-
++ " assertFalse(Target." ++ unpack name ++ "());"
81+
propAssertion = case mProperty of
82+
Just (name, addr) -> Just $
83+
" vm.stopPrank();\n vm.prank(" ++ formatAddr addr ++ ");\n"
84+
++ " assertFalse(Target." ++ unpack name ++ "());"
85+
Nothing -> mEventAssert
86+
preamble = case mEventAssert of
87+
Just _ -> Just (" vm.recordLogs();" :: String)
88+
Nothing -> Nothing
5689
in
5790
object
5891
[ "testName" .= ("FoundryTest" :: Text)
5992
, "contractName" .= cName
6093
, "actors" .= actors
6194
, "reproducer" .= repro
6295
, "propertyAssertion" .= propAssertion
96+
, "preamble" .= preamble
6397
]
6498

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

lib/Echidna/Output/assets/foundry.mustache

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ contract {{testName}} is Test {
1717
}
1818

1919
function test_replay() public {
20+
{{#preamble}}
21+
{{{.}}}
22+
{{/preamble}}
2023
{{#reproducer}}
2124
{{{prelude}}}
2225
{{{call}}}
@@ -35,4 +38,20 @@ contract {{testName}} is Test {
3538
vm.warp(block.timestamp + timeInSeconds);
3639
vm.roll(block.number + numBlocks);
3740
}
41+
42+
{{#preamble}}
43+
/// @dev Checks if a topic matches any known AssertionFailed event signature.
44+
/// Covers all overloads from crytic/fuzzlib (LibLog.sol).
45+
function _isAssertionFailed(bytes32 t) internal pure returns (bool) {
46+
return t == keccak256("AssertionFailed()")
47+
|| t == keccak256("AssertionFailed(string)")
48+
|| t == keccak256("AssertionFailed(string,string)")
49+
|| t == keccak256("AssertionFailed(string,bytes)")
50+
|| t == keccak256("AssertionFailed(string,uint256)")
51+
|| t == keccak256("AssertionFailed(string,int256)")
52+
|| t == keccak256("AssertionFailed(string,address)")
53+
|| t == keccak256("AssertionFailed(string,bool)")
54+
|| t == keccak256("AssertionFailed(string,bytes32)");
55+
}
56+
{{/preamble}}
3857
}

src/test/Tests/FoundryTestGen.hs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ foundryTestGenTests = testGroup "Foundry test generation"
3131
, testCase "fallback function syntax" testFallbackSyntax
3232
, testCase "null bytes in arguments" testNullBytes
3333
, testCase "property test generates assertFalse" testPropertyTestGen
34+
, testCase "event assertion generates recordLogs" testEventAssertionTestGen
3435
, testGroup "Concrete execution (fuzzing)"
3536
[ testForgeStd "solves assertTrue"
3637
"foundry/FoundryAsserts.sol"
@@ -396,6 +397,42 @@ solcSupportsForgeStd = unsafePerformIO $ do
396397
(a, _:b) -> a : splitOn c b
397398
(a, []) -> [a]
398399

400+
-- | Test that event-based assertion failures (CallTest "AssertionFailed(..)")
401+
-- generate Foundry reproducers with vm.recordLogs() and event checks.
402+
testEventAssertionTestGen :: IO ()
403+
testEventAssertionTestGen = do
404+
let
405+
reproducerTx = Tx
406+
{ call = SolCall ("inc", [])
407+
, src = 0x10000
408+
, dst = 0
409+
, value = 0
410+
, gas = 0
411+
, gasprice = 0
412+
, delay = (0, 0)
413+
}
414+
test = mkMinimalTest
415+
{ testType = CallTest "AssertionFailed(..)" (\_ _ -> BoolValue True)
416+
, reproducer = [reproducerTx, reproducerTx, reproducerTx, reproducerTx]
417+
}
418+
generated = TL.unpack $ foundryTest (Just "EventAssertion") defaultPsender test
419+
assertBool ("should not be empty, got: " ++ generated)
420+
(not $ null generated)
421+
assertBool ("should contain vm.recordLogs(), got: " ++ generated)
422+
("vm.recordLogs()" `isInfixOf` generated)
423+
assertBool ("should contain vm.getRecordedLogs(), got: " ++ generated)
424+
("vm.getRecordedLogs()" `isInfixOf` generated)
425+
assertBool ("should contain _isAssertionFailed helper call, got: " ++ generated)
426+
("_isAssertionFailed(" `isInfixOf` generated)
427+
assertBool ("should contain _isAssertionFailed function definition, got: " ++ generated)
428+
("function _isAssertionFailed" `isInfixOf` generated)
429+
assertBool ("should check all fuzzlib overloads, got: " ++ generated)
430+
("AssertionFailed(string,uint256)" `isInfixOf` generated)
431+
assertBool ("should contain assertTrue, got: " ++ generated)
432+
("assertTrue(found" `isInfixOf` generated)
433+
assertBool ("should contain inc() call, got: " ++ generated)
434+
("Target.inc()" `isInfixOf` generated)
435+
399436
mkMinimalTest :: EchidnaTest
400437
mkMinimalTest = EchidnaTest
401438
-- Foundry tests are only generated for solved/large tests.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.0;
3+
4+
// Test that event-based assertion failures generate Foundry reproducers.
5+
// In assertion mode, emitting AssertionFailed() is detected as a failure.
6+
contract EventAssertion {
7+
event AssertionFailed();
8+
9+
uint256 public counter;
10+
11+
function inc() external {
12+
counter++;
13+
if (counter > 3) {
14+
emit AssertionFailed();
15+
}
16+
}
17+
18+
function dummy() external {}
19+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
testMode: assertion
2+
seed: 1234

0 commit comments

Comments
 (0)