Skip to content

Commit 9fb9fe0

Browse files
Allow foundry unit test generation to handle assertion failure with events (#1550)
* allow foundry unit test generation to handle assertion failure with events * fix --------- Co-authored-by: Emilio López <emilio.lopez@trailofbits.com>
1 parent a48b303 commit 9fb9fe0

5 files changed

Lines changed: 119 additions & 9 deletions

File tree

lib/Echidna/Output/Foundry.hs

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

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

4368
-- | Create an Aeson Value from test data for the Mustache template.
4469
-- When a property name and psender are provided, a final assertion is added
4570
-- 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 =
71+
-- When an event assertion is provided, vm.recordLogs() is added at the start
72+
-- and the event check is added at the end.
73+
createTestData :: Maybe Text -> Maybe (Text, Addr) -> Maybe String -> EchidnaTest -> Value
74+
createTestData mContractName mProperty mEventAssert test =
4875
let
4976
senders = nub $ map (.src) test.reproducer
5077
actors = zipWith actorObject senders [1..]
5178
repro = mapMaybe (foundryTx senders) test.reproducer
5279
cName = fromMaybe "YourContract" mContractName
53-
propAssertion = mProperty <&> \(name, addr) ->
54-
" vm.stopPrank();\n vm.prank(" ++ formatAddr addr ++ ");\n"
55-
++ " assertFalse(Target." ++ unpack name ++ "());"
80+
propAssertion = case mProperty of
81+
Just (name, addr) -> Just $
82+
" vm.stopPrank();\n vm.prank(" ++ formatAddr addr ++ ");\n"
83+
++ " assertFalse(Target." ++ unpack name ++ "());"
84+
Nothing -> mEventAssert
85+
preamble = case mEventAssert of
86+
Just _ -> Just (" vm.recordLogs();" :: String)
87+
Nothing -> Nothing
5688
in
5789
object
5890
[ "testName" .= ("FoundryTest" :: Text)
5991
, "contractName" .= cName
6092
, "actors" .= actors
6193
, "reproducer" .= repro
6294
, "propertyAssertion" .= propAssertion
95+
, "preamble" .= preamble
6396
]
6497

6598
-- | 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)