How to Fuzz Smart Contracts Using Echidna Liquity V2 Governance

Table of contents
- Brief Recap of Liquity v2 governance contract
- Who Is This Article For
- Setting Up Echidna
- Writing Fuzzing Test Suites And What Suite Consists Of…
- Echidna Output Corpus Folder Explained
- How To Design A Fuzzing Test Suite, Increase Coverage And Debugging Techniques
- Fuzzing Configuration Explained And Uses Cases
- Case Study/ Demo Of Invariants In Governance Contract
- Beginner Mistakes To Avoid
- Conclusion

Brief Recap of Liquity v2 governance contract
Before jumping into fuzzing suite, let’s briefly recap how Liquity v2’s governance contract works.
Liquity v2 is a decentralized protocol where users can borrow BOLD (a USD-pegged stablecoin) by depositing ETH or WETH as collateral. A key feature of Liquity v2 is its governance layer, which allocates 25% of the borrowing interest fees to fund community-driven initiatives.
The governance contract allows LQTY token holders to vote or veto on these initiatives, allocating funds accordingly based on initiative support. It uses a staking model where users stake LQTY, allocate it to initiatives, and participate in voting/vetoing.
To learn more about Liquity V2 Governance, click here to check out my previous blog, where I have explained the entire governance contract.
Who Is This Article For
This article is for smart contract developers, auditors, and security researchers who want to learn how to build effective fuzzing test suites using Echidna for Solidity protocols. If you already understand Solidity and want to improve your testing skills beyond unit tests, especially in invariant-based fuzzing and debugging with Foundry, this article can help you get started in enhancing your invariant writing skills.
Setting Up Echidna
If you want to set up, there are docs by Trail of bits below that you can use to set up Echidna.
After the environment is set, let's understand the structure of a typical fuzzing test suite.
Writing Fuzzing Test Suites And What Suite Consists Of…
Structure of a fuzz test suite -Most fuzzing suites consist of specific files that you'll also find in other people's fuzz suites. A well-structured fuzzing test suite typically includes several core files.
Setup - This simple file contains the whole setup of the smart contract that you want to fuzz just like we do in foundry
Properties/Invariants - This is the file that the entire fuzzing process focuses on, where we write the invariants or properties we want the fuzzer to test.
Target Functions - Since fuzzing runs are limited, we avoid wasting them on calling view functions. Now since there are two options for the fuzzer first could call the all the functions in the smart contract but we know there would no use of calling view as it is just read value and we don't want to waste our fuzz runs in some by calling read only functions as this will waste our fuzz runs,
So here the second option comes in play where we could tell fuzzer to call functions so that it could only call important functions which change the state of the smart contract cause we don't want to waste our fuzz runs on calling again and again on view functions as they won't change the state.BeforeAfter(Ghost Variables): Ghost variables are not integral to the original contract. Instead, they are introduced within the fuzzing suite to monitor specific aspects of the contract’s execution. This monitoring entails tracking the contract’s state and verifying whether the state of the smart contract has been modified before and after executing the state changing function e.g(tracking the state before and after the
depositLQTY
function is called. We can check if a transaction has been made or not and, using ghost variables, cross verify with smart contract state variable whether the deposit has occurred).CryticTester - Consider this file as the entry point for the fuzzer, where it will initiate the fuzzing test suite, verifying invariants and executing target functions.
CryticToFoundry/Debug - This file is the most valuable asset in the fuzzing suite. It enables us to debug target and invariant functions that may not be working in the fuzzing suite. While we can create unit tests in Foundry to verify the functionality of smart contracts, debugging these functions in Echidna is not possible. Foundry provides a solution to this issue by allowing us to debug our target and invariant functions . To use this file, simply import the Setup file we mentioned earlier and begin debugging your fuzzing suite functions using foundry.
Additional Application:
If you find a bug using Echidna, it will provide the call sequence of the functions that led to the bug. This information can be used to reproduce the same scenario in Foundry to prove that the bug found is not a false positive.
We will discover this further part of the article where debugging and increasing the coverage with example is shown.
With the suite components in place, let’s understand what happens during fuzzing and where the output is stored.
Echidna Output Corpus Folder Explained
Echidna maintains a corpus
folder to store important data from fuzzing sessions. Let's go through the significance and use of each file.
Reproducers - In this txt file echidna gives the call sequence of functions that led to finding of the bug.
Reproducers-unshrunk - This file contains the original, full, unoptimized sequence of function calls that caused the invariant to fail before shrinking.It is the raw, unfiltered input that caused a bug. It includes every single call made by Echidna leading up to the failure, which can be long and noisy.
Shrinking- When Echidna finds a failing input, it tries to "shrink" it.Think of shrinking like minimizing a test case.
For example, if it found a bug with a sequence of 20 function calls, maybe only 3 of them were actually needed to trigger the bug, shrinking also sees if there is any other to reproduce that same bug with some other function calls.
Coverage - Whenever fuzzer uncovers a unique call sequence of functions, it adds a file to this folder in .txt format.
.txt - It just the text format of coverage of the html file.
.icov - It is machine readable format of the html, txt file.
html - It is used to see the coverage, which part of the code is covered by the fuzzer and which was not.
Green - indicates that part of the code was successfully covered by the fuzzer.
Red - indicates that a particular portion of the code was not effectively covered by the fuzzer.
* - indicates that a particular line of code was executed by the fuzzer without encountering any errors.
*r - indicates that a line of code was executed but subsequently reverted at some point in time.
r - indicates that an entire section of the code was reverted and never executed at all.
Now that we understand what Echidna's output looks like, let's see how we should design a fuzzing test suite.
How To Design A Fuzzing Test Suite, Increase Coverage And Debugging Techniques
As we mentioned earlier in the blog, there are 3-4 essential files needed to design a good fuzzing suite. Let's explore what to keep in mind for an effective fuzzing suite.
Handlers or Target Functions File - Think of fuzzing runs as a limited resource we want to use them efficiently. If we let Echidna call the smart contract functions directly, many fuzzing runs could be wasted on view functions or public state variable accessors, which don’t change contract state. That’s not ideal, because we want to test whether invariants hold true under changing states.
To avoid this, instead of having the fuzzer call the contract directly, we write wrapper functions (also known as handlers) that explicitly call only the state changing functions we're interested in for example, depositLQTY, withdrawLQTY, registerInitiative, or allocateLQTY in the Governance contract.
However, these functions often require certain preconditions (like non-zero deposits or valid registration timing). So, we design our wrapper functions such as handler_clampedDepositLqtyUser (it sees if the userProxy was deployed or not) or handler_clampedWithdrawLqtyUser(It sees if the users has even staked some LQTY in the contract or not) to both ensure those conditions are met and avoid unnecessary reverts. This way, we prevent fuzz runs from being wasted on:
Failing transactions due to unmet conditions
Calling non-essential functions like view functions or public variable accessors.
NOTE:- We don't want to make our handlers too restrictive, as that could prevent meaningful state exploration. At the same time, we shouldn't leave them too open-ended, as that can lead to wasted fuzzing runs on invalid or irrelevant inputs. The ideal approach lies in finding a balance a grey area — where the handlers guide the fuzzer just enough to reach interesting states without over-constraining it. Striking this balance is something we refine over time through experience and by writing more fuzz tests.
Invariants or Properties Functions File - In Echidna, property functions are the core of what we’re trying to verify, they represent the invariants or correctness conditions we expect to always hold true. These functions are typically named with the echidna_ prefix which could also be changed in the config file (e.g., echidna_invariantShouldAlwaysHold) and In property mode, a true return value signifies the property’s adherence, while a false return value indicates a violation. In assertion mode, assertions are employed to enforce the property’s validity.
These property functions live in a dedicated contract (often referred to as the properties contract or properties file), which is where we define all the invariants we care about testing. This separation helps keep the test logic clean and focused.
During fuzzing, Echidna will repeatedly call our handler functions (to mutate the contract state) and then evaluate your property functions to check if any invariant has been broken. A failing property function signals a potential bug or unexpected behaviour.
So, while handler functions drive the contract into different states, property functions act as checkpoints, asserting that those states are always safe and valid. Designing good property functions just like handlers is something that improves with deeper understanding of the protocol's logic.
Methods For Debugging Handlers and Invariants - While Echidna's HTML coverage report helps us visualize which lines of code were executed during fuzzing, it doesn’t explain why certain branches weren’t covered or what went wrong. For example, in the case of a handler like
handler_clampedDepositLqtyUser(uint8 userIndex, uint256 lqtyAmt)
, you might notice that only part of the handler function is covered. To debug this effectively, we turn to Foundry.Foundry becomes extremely useful here. You can import the same setup files used in the Echidna fuzzing suite into your Foundry tests. Instead of calling smart contract functions directly (as in traditional unit tests in foundry), you can call the specific handler functions you want to investigate.
Within Foundry, you can use like assert() and console.log() to inspect the internal flow of your handler or property function. This allows you to pinpoint where a revert might be happening, which conditions aren’t met, or why a line isn't being reached — all things that Echidna itself doesn’t explicitly report.
Attached below are some images for your reference. Initially, the handler was not functioning correctly. However, after debugging in Foundry, it was successfully covered. It is possible that there might be a minor error, but Foundry is very valuable tool in resolving and debugging issues with the handler and invariant functions and you could write these debugging functions in
CryticToFoundry
file.NOTE:- This is a common situation you’ll encounter while writing or refining both handler and property functions. Using Foundry for step-by-step debugging complements Echidna perfectly, making the entire fuzzing process much more productive and insightful.
Debug reproducers file - When Echidna discovers a failing input that breaks an invariant, it generates a reproducers file containing the exact sequence of function calls that led to the bug. This file is essential for debugging, as it provides a concrete path to reproduce the issue.
Here’s how the typical workflow looks:
After a bug is found, a .txt file appears in the reproducers/ folder.
This file includes the full call sequence and inputs used by Echidna to trigger the failure.
To make it easier to read, the .txt file can be converted to JSON format and further prettified for easier parsing and understanding.
Once we understand the input sequence, we write a Foundry test that mirrors the failing scenario. This allows us to reproduce the bug in a controlled environment and debug it using standard tools like assert() and console.log().
For instance, in below case, we manually introduced a bug in the Governance contract by commenting out a critical line which updates the offset of the user. Echidna successfully detected the invariant violation as we defined the invariant that once user deposits the offset should increase for the user. And then using the reproducers file, we traced the call sequence that triggered the issue and replicated it in Foundry allowing us to understand why the invariant failed and how to fix it.
Governance Contract line that we commented out
Reproducers JSON file
Due to the bug that we introduced, we could see that deposit was made of 1 LQTY but offset didn't increase.
Testing in foundry to debug the problem
After fixing the bug we could see that unallocated offset increased.
NOTE:- We could also automate this entire process using a tool by Enigma-Dark. It automatically generates the Foundry test with the reproducers file. I discovered this while writing the blog, but I haven't tested it yet. https://github.com/Enigma-Dark/fuzz-trace-parser
Once our suite and debugging tools are in place, we can fine-tune fuzzing further using configuration options.
Fuzzing Configuration Explained And Uses Cases
Echidna provides several configuration options that help tailor the fuzzing process to specific needs. These settings can be defined in a .yaml configuration file or passed as command-line arguments. Since fuzzing runs are a limited and valuable resource, it's important to guide the fuzzer toward meaningful execution paths and that's exactly what this configuration enables. and we have to utilise it properly, so that's why this file allows us to fine-tune everything from how many fuzzing runs are executed to how contracts are compiled and tested and much more.
Here’s a brief overview of some key parameters and their use cases:
testLimit - Defines the number of fuzzing runs to execute before termination or the number of test sequences to run.
testMode - Specifies the type of test, usually assert, property, optimization.
assert - Echidna monitors assert statements within functions.
property - Echidna focuses on bool-returning functions that represent invariants. (this mode is used by default)
optimization: Echidna will attempt to find a sequence of transactions to maximize the value returned. you could read about optimization mode in detail in the blog by trail of bits Blog and Docs .
These modes help Echidna understand how to interpret function outputs.
shrinkLimit - Sets a cap on the number of steps Echidna takes while minimizing a failing test case (called shrinking). Shrinking simplifies the input that caused a failure, making it easier to understand and reproduce.
prefix - Allows the fuzzer to identify invariant functions, which are the functions we want the fuzzer to check or break. By default, it is
echidna
, but we can change it to anything we usually use, such asinvariant_
orproperty_
.cryticArgs - allows you to specify the compiler we want to use in the project. The default compiler used by Echidna is the Solc compiler, but you need to install a specific Solc compiler each time specific to the project needs. We could change to the Foundry compiler or Hardhat so that tools can handle the compilation more easily. For example, you could use
cryticArgs: [“—foundry-compile-all”]
.balanceContract / balanceAddr - Sets the initial ETH balance of a contract or address at the start of fuzzing. Essential for simulating realistic economic scenarios or ensuring functions don't fail due to lack of funds.
allContracts - When set to true, Echidna will fuzz every contract in your project, instead of just the specified target.
In assert mode, Echidna will test all functions in every contract containing an assert() statement.
In property mode, it will evaluate all public or external functions returning bool (with the given prefix).
While this is helpful in small projects or tightly-coupled systems, it's usually set to false in large protocols. Otherwise, fuzz runs might get wasted on contracts or functions irrelevant to the invariants you're actually trying to test.
To avoid this, we define a dedicated test entry contract (like CryticTester) that acts as a gateway to all relevant logic, keeping fuzzing scoped and efficient.
seed: Sets the random seed for the fuzzer, ensuring the reproducibility of fuzz runs. If you discover a bug and wish to replay the same fuzzing session in the future, use the same seed. The seed is generated by Echidna at the end of the fuzz run. Therefore, simply retain the seed for the bug you wish to replay and subsequently enter it into the .yaml file.
Although Gustavo Grieco confirmed that this is theoretically true to reproduce, it is challenging for Echidna to reproduce due to various factors. The optimal approach is to increase the shrinkLimit.
sender - These are all the address that you want your smart contract to interact with. Just like in foundry you make address using
makeAddr
in echidna we bysender: ["0x100000", "0x200000", "0x300000", "0x400000", "0x500000","0x60000"]
.corpusDir - Sets the directory where Echidna stores the corpus i.e., the pool of interesting inputs, reproducers, coverage discovered during fuzzing.
Workers: Defines the number of CPU cores to run. This significantly improves the fuzzing speed of the fuzzer, resulting in more exploration and deeper investigation of the contract. However, once a certain ceiling of new path and call sequence discovery is reached, even with increased worker counts, further improvements may be minimal.
In summary, increasing the number of workers reduces the overall execution time. However, there are certain challenges associated with increasing the number of workers, which can be observed in this GitHub issue.
filterBlacklist - Instead relying solely on
allContracts : false
and external testing, we can effectively utilise thefilterBlacklist
andfilterFunctions
functionalities together.
WhenfilterBlacklist : true
, the functions specified withinfilterFunctions
will be excluded from execution. Conversely,
whenfilterBlacklist : false
, the handlers and invariants defined within the fuzzing suite can be incorporated intofilterFunctions
, ensuring that only those specified functions are executed.
This approach provides a control mechanism over which handlers or invariants are exercised, allowing for explicit definition of the functions to be fuzzed by Echidna.filterFunctions - Lets you explicitly define a list of function selectors that Echidna should fuzz. This gives fine-grained control over which handlers or invariants function should be called.
coverageFormats: [“txt”,”html”,”lcov”] - These are the file formats in which the code coverage will be displayed to the user. You can select the format you prefer for the coverage report, by default each file is shown in the corpus.
deployer - The address which echidna will use deploy the smart contract.
Each of these configuration plays a role in shaping the fuzzing strategy. Tuning them thoughtfully can help make fuzzing more efficient, targeted, and effective for your protocol.
With your suite configured and debugged, the next step is writing and testing actual invariants. In next part , we’ll walk through real examples used in liquity V2.
Case Study/ Demo Of Invariants In Governance Contract
In this section, we walk through a few invariants implemented for the Governance contract and explain their purpose, structure, and how they contribute to ensuring the correctness of the Governance contract. These case studies could help get an idea on how to design invariants, the logic behind them, and how fuzzing could help gain confidence over the codebase's correctness or uncover subtle issues.
We’ll break down each invariant using the following structure:
Purpose - What is the purpose of the invariant, and why is it necessary?
Preconditions – What constraints must be met before executing the action?
Action – The main operation under test.
Ghost Variables – Snapshots of relevant state before and after the action.
Postconditions – The expected outcome or assertion that must always hold.
invariant_stakedLQTYTokenBalanceOfUserShouldIncreaseWhenDeposited
Purpose: This invariant function ensures that when a user deposits LQTY tokens, the offset of the msg.sender should increase. This is because Voting Power (V.P.) is calculated as the product of the number of tokens deposited and the block.timestamp. Therefore, this function verifies that if a user deposits tokens and the offset does not increase, there may be a bug in the smart contract.
Comments have been added to the code to illustrate the structure of an invariant function.
function invariant_stakedLQTYTokenBalanceOfUserShouldIncreaseWhenDeposited(uint8 userIndex, uint256 lqtyAmt) public { // Pre Conditions if (lqtyAmt == 0) return; (address user, address proxy) = _getRandomUser(userIndex); uint256 userBalance = lqty.balanceOf(user); lqtyAmt = lqtyAmt % userBalance; if (lqtyAmt == 0 || userBalance == 0) return; // Ghost Variables before __before(user); uint256 beforeUserEOALqtyBalance = _before.userLqtyBalance[user]; (uint256 beforeLQTYStakedOfUser,,, ) = governance.userStates(user); // Action hevm.prank(user); lqty.approve(proxy, type(uint256).max); hevm.prank(user); governance.depositLQTY(lqtyAmt); // Ghost Variables after __after(user); uint256 afterUserEOALqtyBalance = _after.userLqtyBalance[user]; (uint256 afterLQTYStakedOfUser,,, ) = governance.userStates(user); // Post Conditions uint256 balanceOfLqtyInUserEOA = beforeUserEOALqtyBalance - afterUserEOALqtyBalance; uint256 balanceOfStakedLqtyByUser = afterLQTYStakedOfUser - beforeLQTYStakedOfUser; emit BalanceOfLqtyOfUserInEOAAndStaked(balanceOfLqtyInUserEOA, balanceOfStakedLqtyByUser); // Assert assert(balanceOfLqtyInUserEOA == balanceOfStakedLqtyByUser); }
Preconditions: This section performs early exits and validations before executing the main logic. It serves as a gatekeeper, ensuring that unnecessary logic is avoided when inputs are invalid or meaningless. However, it is crucial to strike a balance between enforcing preconditions and avoiding excessive restrictions on the invariant function. Additionally, well-defined preconditions can prevent false positives and irrelevant fuzzing cycles.
In the invariant function, the pre-conditions verify whether the quantity amount by the fuzzer and the user’s balance of quantity are zero. If any of these conditions is not met, the pre-conditions are reverted.Action: This section contains the actual function calls that mutate state, typically the operation under test. In this invariant function the action performed is deposit of lqty token by the user.
Ghost Variables Before/After: These variables are defined outside the Governance contract to track the state of the smart contract. They enable delta-based assertions and validation, providing additional insights into the contract’s behaviour.
In the invariant function, we monitor the user’s lqty balance in their EOA and the amount of lqty tokens they have staked.Postconditions: After capturing snapshots of the contract’s state before and after a specific action, we calculate the relevant value change and validate it as the heart of the invariant. This step ensures that the contract’s behaviour is logically sound and aligns with our expectations.
In this invariant function, we verify whether the lqty balance deducted from the user’s EOA matches the same quantity amount staked in the Governance contract.
invariant_totalSumOfAllocatedLqtyOfUserEqualToInitiativesLqty
Purpose: This invariant function ensures that the total allocatedLQTY across all users must equal the total voteLQTY + vetoLQTY across all initiatives.
function invariant_totalSumOfAllocatedLqtyOfUserEqualToInitiativesLqty() public view{ uint256 totalSumOfUsersAllocatedLqty; uint256 totalSumOfVoteAndVetoOfAnInitiative; for(uint256 i; i < users.length; i++){ (,,uint256 userAllocatedLqty,) = governance.userStates(users[i]); totalSumOfUsersAllocatedLqty += userAllocatedLqty; } for(uint256 i; i < deployedInitiatives.length; i++){ (uint256 voteLqtyOfInitiative ,, uint256 vetoLqtyOfInitiative ,,) = governance.initiativeStates(deployedInitiatives[i]); totalSumOfVoteAndVetoOfAnInitiative += voteLqtyOfInitiative + vetoLqtyOfInitiative; } // Post Conditions assert(totalSumOfUsersAllocatedLqty == totalSumOfVoteAndVetoOfAnInitiative); }
Preconditions: There are no preconditions or ghost variables for this invariant, as it simply checks the consistency of aggregate values across two mappings.
Action: We loop through:
All users to sum their allocatedLQTY
All initiatives to sum voteLQTY and vetoLQTY
Postconditions: We assert that the total allocated LQTY across users equals the total initiative side LQTY. This ensures consistency across the protocol's accounting for voting power.
invariant_initiativeShouldReturnSameStatus
Purpose: This invariant function verifies that for any given epoch, initiative status remains consistent, except in cases where specific controlled transitions are allowed that we are checking with
if
statements inside of afor
loop.In this invariant function, we verify pre-conditions using ghost variables. We check if we are in the same epoch. Once verified, at the end we verify with an assert or post-conditions that the status of the initiative has not changed.
function invariant_initiativeShouldReturnSameStatus() public { // Pre Conditions checking with the help of Ghost variables if(_before.epoch == _after.epoch) { for(uint256 i;i <deployedInitiatives.length; i++){ address initiative = deployedInitiatives[i]; emit InitialBeforeAfterStatus(uint8(_before.initiativeStatus[initiative]) , uint8(_after.initiativeStatus[initiative])); if(_before.initiativeStatus[initiative] == IGovernance.InitiativeStatus.NONEXISTENT && _after.initiativeStatus[initiative] == IGovernance.InitiativeStatus.WARM_UP){ continue; } if(_before.initiativeStatus[initiative] == IGovernance.InitiativeStatus.CLAIMABLE && _after.initiativeStatus[initiative] == IGovernance.InitiativeStatus.CLAIMED){ continue; } if(_before.initiativeStatus[initiative] == IGovernance.InitiativeStatus.UNREGISTERABLE && _after.initiativeStatus[initiative] == IGovernance.InitiativeStatus.DISABLED){ continue; } // Post Conditions assert(_before.initiativeStatus[initiative] == _after.initiativeStatus[initiative]); } } }
By designing targeted invariants and pairing them with good handler logic and smart use of ghost variables, we ensure that the protocol maintains key safety and consistency of properties/invariants even under intense pressure.
Beginner Mistakes To Avoid
Designing a fuzzing suite especially as an auditor requires a slightly different mindset than performing a manual audit. Below are some common mistakes that we could easily avoid as beginners that we should be aware of:
Over-investing time in deep code analysis:
While it's important to understand the high-level design of the protocol and how key smart contracts interact, spending excessive time analyzing every single line of code (as we do in manual audits) is not the most efficient approach when writing a fuzzing suite. Once you grasp the protocol’s flow and core mechanics, it's better to setup fuzzing suite, start writing handlers and property functions early. Fuzzing is an iterative process, and many insights come while debugging and refining tests, not just from reading code.Over-constraining handlers and property functions:
We've discussed earlier how excessive restrictions can hinder the fuzzer’s ability to reach interesting states. Instead of building overly strict handlers from the start, consider defining both clamped (restricted) and unclamped(un-restricted) versions. This dual approach gives the fuzzer more freedom in exploring paths while still allowing structured control where needed.Assuming all bugs are real:
Fuzzers could report bugs that turn out to be false positives. These can arise due to:Incorrectly defined invariants
Incomplete or broken handler logic
Flawed setup of fuzzing suite
Always inspect the reproducers file and try to replicate the issue in Foundry to confirm whether it's a true bug. So just debug the each and every function that was called in the reproducers file until confident.
Conclusion
Fuzzing is an iterative process that involves writing tests → running tests → failed tests → debugging it → refining the tests.
The goal of fuzzing isn’t just to “find bugs.” It’s to apply pressure to the contract logic and ensure that your assumptions captured in invariants always hold under unexpected inputs and edge cases.
Even when no bugs are found, building a solid fuzzing suite adds immense value:It formalizes your understanding of the protocol
It acts as a safety net during future upgrades
It complements manual audits and unit tests
Just like unit testing has become a standard practice for smart contracts, fuzzing could also be a default part of the auditing and development workflow.
Note: This smart contract was initially fuzzed by Recon. I performed shadow fuzzing to learn fuzzing techniques. This article serves as a beginner’s introduction to fuzzing a smart contract and highlights mistakes that can be easily avoided.
Thank you for reading the article! I hope you found it insightful and helpful in understanding the importance of fuzzing in smart contract development. If you have any questions or thoughts, feel free to share them. I'd love to hear your feedback.😄
Github Repo Shadow Fuzzing By Me
Subscribe to my newsletter
Read articles from Gurkirat singh directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Gurkirat singh
Gurkirat singh
Smart contract auditor and fuzzer. Found 150+ bugs across 20+ protocols. Don’t trust a protocol until its invariants do. 😎