Took a few days off due to personal commitments, but I'm back with Day 32 and it's a heavy one.
Follow the series on Medium | Twitter | Future
Jump into Web3ForHumans on Telegram and let's build together.
I asked ChatGPT for a simple Solidity contract yesterday. "Write me a vault where users can deposit ETH and earn rewards," I said.
Within 30 seconds, I had code. It compiled. Logic looked solid. Tests passed.
So I almost deployed it.
Then I ran it through Foundry's fuzzer and Slither's static analysis. And what I found made me realize: that contract would have been drained within hours on mainnet. Not because the logic was wrong. But because it had three separate vulnerabilities that ChatGPT confidently included without a second thought.
This is Day 32 of the 60‑Day Web3 journey, still in Phase 3: Development. Today, I'm showing you exactly what went wrong, how I found it, and the systematic workflow that turns "looks good" into "actually safe."
Because the truth is: AI-generated code compiles. It passes basic tests. But it fails under real-world attack vectors — and you won't see it coming unless you know what to look for.
Why AI Code Is Dangerous (And Why You Don't Know It Yet)
AI coding assistants are incredible. They remove friction, generate boilerplate fast, and let you prototype in seconds. But here's the problem: they don't understand incentives, money, or attacks. They pattern-match code that "looks right" based on training data. And in Web3, "looks right" is not the same as "actually safe."
Three things make AI-generated Solidity particularly risky:
Reentrancy blindness — AI will happily write code that sends funds before updating balances (Day 30's lesson). It doesn't know this is dangerous.
Missing access controls — Functions that should be restricted to the owner get shipped as
publicbecause the AI was trained on too many generic examples.Unchecked external calls — AI ignores return values from token transfers and low-level calls, assuming they'll always work perfectly.
The contract compiles. The tests pass. You think you're good. Then an attacker calls your contract in ways you never imagined, and the game is over.
Today, we fix this. Not by being smarter than AI, but by running it through a gauntlet of automated tools that catch what humans miss.
Prerequisites: Install Foundry and Slither (2026 Edition)
If you want to follow along (and you should), you need two tools: Foundry and Slither. Both are free, open-source, and industry standard.
Install Foundry (Latest 2026 Version):
On Mac/Linux, open your terminal and run:
curl -L https://foundry.paradigm.xyz | bash
foundryup
On Windows, use WSL2 (Windows Subsystem for Linux) or download the installer from getfoundry.sh.
Verify installation:
forge --version
cast --version
You should see forge 0.2.24+ and cast version info.
Install Slither (For Static Analysis):
Slither requires Python 3.9+. If you have Python installed, run:
pip install slither-analyzer
Verify:
slither --version
You should see Slither 0.10.0+ or newer.
Set Up Your Project:
forge init vault-security-demo
cd vault-security-demo
Now you're ready. Let's build something dangerous, then fix it.
The AI-Generated Contract (Before Security Fixes)
Here's exactly what ChatGPT generated when I asked for a "simple rewards vault":
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SimpleVault {
mapping(address => uint256) public balances;
address public owner;
uint256 public rewardRate = 10; // 10% per year
constructor() {
owner = msg.sender;
}
function deposit(uint256 amount) public {
require(amount > 0, "Amount must be positive");
balances[msg.sender] += amount;
}
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount;
}
function setRewardRate(uint256 newRate) public {
require(msg.sender == owner, "Only owner");
rewardRate = newRate;
}
function emergencyWithdraw() public {
require(msg.sender == owner, "Only owner");
(bool success, ) = msg.sender.call{value: address(this).balance}("");
require(success, "Transfer failed");
}
receive() external payable {}
}
Paste this into src/SimpleVault.sol and compile:
forge build
Result: Compiles perfectly. Zero warnings. This is where 90% of developers stop and deploy. Big mistake.
Problem #1: The Reentrancy Time Bomb
Look at the withdraw() function. The order is:
- Check:
require(balances[msg.sender] >= amount, ...) - Send ETH:
msg.sender.call{value: amount}("") - Update state:
balances[msg.sender] -= amount
This is backwards. The contract sends ETH before updating the balance. If the recipient is a contract with a receive() function, it can call back into withdraw() again — before the balance is reduced. Boom. Reentrancy.
Let's prove it with a test. Create test/SimpleVault.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/SimpleVault.sol";
contract ReentrancyAttacker {
SimpleVault vault;
uint256 attackCount = 0;
constructor(address _vault) {
vault = SimpleVault(_vault);
}
function attack() external payable {
vault.deposit{value: 1 ether}();
vault.withdraw(1 ether);
}
receive() external payable {
attackCount++;
if (attackCount < 2) {
vault.withdraw(1 ether);
}
}
}
contract SimpleVaultTest is Test {
SimpleVault vault;
ReentrancyAttacker attacker;
function setUp() public {
vault = new SimpleVault();
attacker = new ReentrancyAttacker(address(vault));
}
function testReentrancy() public {
vm.deal(address(vault), 5 ether);
vm.deal(address(attacker), 1 ether);
uint256 vaultBalanceBefore = address(vault).balance;
attacker.attack();
uint256 vaultBalanceAfter = address(vault).balance;
// The attacker should only be able to withdraw 1 ether
// But due to reentrancy, they'll get 2+ ether
console.log("Vault balance before:", vaultBalanceBefore);
console.log("Vault balance after:", vaultBalanceAfter);
console.log("Attacker drained:", vaultBalanceBefore - vaultBalanceAfter);
}
}
Run it:
forge test -vv
Result: The attacker drains more ETH than they deposited. This is the reentrancy exploit in action.
Problem #2: Missing Input Validation
In setRewardRate(), there's no upper limit check. An owner (or attacker who compromises the owner key) can set rewardRate to 1,000,000. The contract doesn't break, but users get screwed.
In deposit(), there's no check for the actual ETH sent. Someone could call deposit(1000) without sending any ETH, and the balance gets credited anyway.
These aren't apocalyptic bugs, but they're the kind that audit firms flag.
Problem #3: Unchecked External Calls
In emergencyWithdraw(), the contract sends ETH to the owner:
(bool success, ) = msg.sender.call{value: address(this).balance}("");
require(success, "Transfer failed");
This looks safe, it checks success. But here's the trap: if the owner is a contract that reverts on receive, all funds become locked. The contract can't recover them.
More importantly, there's no event emitted. If something goes wrong, there's no on-chain record of what happened.
Step 1: Run Foundry's Fuzz Tests (Catch the Bugs Automatically)
Let's add a fuzz test that tries random withdrawal amounts:
function testWithdrawFuzz(uint256 amount) public {
amount = bound(amount, 1, 10 ether);
vm.deal(address(vault), 100 ether);
vm.deal(address(this), amount);
vault.deposit{value: amount}();
uint256 balanceBefore = vault.balances(address(this));
vault.withdraw(amount);
uint256 balanceAfter = vault.balances(address(this));
assertEq(balanceAfter, 0, "Balance should be zero after withdrawal");
}
Run fuzzing with 10,000 random inputs:
forge test --fuzz-runs 10000 -vv
Result: Fuzz test fails with reentrancy scenario. Foundry caught the bug automatically.
Step 2: Run Slither (Catch What Fuzzing Misses)
Now run static analysis:
slither src/SimpleVault.sol
Result: Multiple findings:
[HIGH] Reentrancy in SimpleVault.withdraw():
- External call before state update
[MEDIUM] Missing Input Validation:
- setRewardRate() allows unlimited values
- deposit() doesn't validate msg.value
[MEDIUM] Missing Event:
- emergencyWithdraw() performs critical action without logging
Slither caught all three problems in seconds. Combined with fuzz testing, you now have a complete picture of what's broken.
Step 3: Fix It Using Best Practices
Now let's fix the contract using modern Solidity 0.8.24 features and OpenZeppelin v5.x:
forge install OpenZeppelin/openzeppelin-contracts@5.0.0
Create the fixed version in src/SimpleVault.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract SimpleVault is ReentrancyGuard, Ownable {
mapping(address => uint256) public balances;
uint256 public rewardRate = 10;
// Events for transparency
event Deposit(address indexed user, uint256 indexed amount, uint256 timestamp);
event Withdraw(address indexed user, uint256 indexed amount, uint256 timestamp);
event RewardRateUpdated(uint256 newRate);
event EmergencyWithdrawal(uint256 amount, uint256 timestamp);
constructor() Ownable(msg.sender) {}
function deposit(uint256 amount) external payable nonReentrant {
// Input validation
require(amount > 0, "Amount must be positive");
require(msg.value == amount, "ETH value mismatch");
// Update state before external calls (Checks-Effects-Interactions)
balances[msg.sender] += amount;
emit Deposit(msg.sender, amount, block.timestamp);
}
function withdraw(uint256 amount) external nonReentrant {
// Checks
require(balances[msg.sender] >= amount, "Insufficient balance");
require(amount > 0, "Amount must be positive");
// Effects (update state FIRST)
balances[msg.sender] -= amount;
// Interactions (external calls LAST)
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
emit Withdraw(msg.sender, amount, block.timestamp);
}
function setRewardRate(uint256 newRate) external onlyOwner {
// Input validation with reasonable bounds
require(newRate > 0, "Rate must be positive");
require(newRate <= 100, "Rate cannot exceed 100%");
rewardRate = newRate;
emit RewardRateUpdated(newRate);
}
function emergencyWithdraw() external onlyOwner nonReentrant {
uint256 balance = address(this).balance;
require(balance > 0, "No funds to withdraw");
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Emergency withdrawal failed");
emit EmergencyWithdrawal(balance, block.timestamp);
}
receive() external payable {
// Revert if someone sends ETH directly (not through deposit)
revert("Use deposit() function");
}
}
Key improvements:
- ReentrancyGuard — Prevents reentrancy attacks
- Checks-Effects-Interactions pattern — State updates BEFORE external calls
- Input validation — Bounds checks on reward rate, amount > 0
- Events for everything — Deposit, withdraw, config changes all logged
- Ownable from OpenZeppelin — Standard access control
- nonReentrant modifier — Applied to all functions that transfer funds
- Better receive() — Rejects random ETH transfers
Compile the fixed version:
forge build
Result: Compiles perfectly.
Step 4: Re-Test and Re-Analyze
Update your test file with comprehensive tests:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/SimpleVault.sol";
contract SimpleVaultFixedTest is Test {
SimpleVault vault;
address user = address(0x1);
function setUp() public {
vault = new SimpleVault();
}
function testDepositAndWithdraw() public {
vm.deal(user, 2 ether);
vm.prank(user);
vault.deposit{value: 1 ether}(1 ether);
assertEq(vault.balances(user), 1 ether);
vm.prank(user);
vault.withdraw(1 ether);
assertEq(vault.balances(user), 0);
}
```
markdown
function testCannotWithdrawMore() public {
vm.deal(user, 1 ether);
vm.prank(user);
vault.deposit{value: 1 ether}(1 ether);
vm.prank(user);
vm.expectRevert("Insufficient balance");
vault.withdraw(2 ether);
}
function testRewardRateValidation() public {
vm.expectRevert("Rate cannot exceed 100%");
vault.setRewardRate(101);
vault.setRewardRate(50);
assertEq(vault.rewardRate(), 50);
}
function testDepositValueMismatch() public {
vm.deal(user, 1 ether);
vm.prank(user);
vm.expectRevert("ETH value mismatch");
vault.deposit{value: 0.5 ether}(1 ether);
}
function testEmergencyWithdraw() public {
vm.deal(address(vault), 10 ether);
vault.emergencyWithdraw();
assertEq(address(vault).balance, 0);
}
function testReceiveRevert() public {
vm.deal(user, 1 ether);
vm.prank(user);
vm.expectRevert("Use deposit() function");
(bool success, ) = address(vault).call{value: 1 ether}("");
require(!success);
}
function testDepositFuzz(uint256 amount) public {
amount = bound(amount, 1, 100 ether);
vm.deal(user, amount);
vm.prank(user);
vault.deposit{value: amount}(amount);
assertEq(vault.balances(user), amount);
}
function testMultipleDepositsAndWithdraw(uint256 amount1, uint256 amount2) public {
amount1 = bound(amount1, 1, 50 ether);
amount2 = bound(amount2, 1, 50 ether);
vm.deal(user, amount1 + amount2);
vm.prank(user);
vault.deposit{value: amount1}(amount1);
vm.prank(user);
vault.deposit{value: amount2}(amount2);
assertEq(vault.balances(user), amount1 + amount2);
vm.prank(user);
vault.withdraw(amount1 + amount2);
assertEq(vault.balances(user), 0);
}
}
Run the complete test suite:
bash
forge test -vv
Result: All tests pass. Now run Slither on the fixed contract:
bash
slither src/SimpleVault.sol
Result: No HIGH or MEDIUM issues. Maybe a few LOW severity info notices, but nothing critical.
The Full Security Workflow You Should Copy
Here's the exact process I followed, and the one every developer should adopt:
- Get AI-generated contract → Paste into Foundry
-
Compile →
forge build - Write unit tests → Happy paths + edge cases
-
Run tests →
forge test - Add fuzz tests → Let Foundry try 10,000 random inputs
-
Run fuzz tests →
forge test --fuzz-runs 10000 -
Run static analysis →
slither src/ - Read findings → Understand what each tool caught
- Fix issues → Apply patterns (CEI, ReentrancyGuard, validation)
- Re-test + re-analyze → Loop until all green
- Deploy to testnet → Only after passing all checks
This workflow takes 90 minutes per contract. It's the difference between "code that compiles" and "code that won't drain users' funds."
Why This Actually Matters
That AI-generated contract looked "done." It compiled. It had logic. It passed manual tests. But without this workflow, I would have shipped a reentrancy bug to testnet — or worse, mainnet.
The tools don't replace your brain. They extend it:
- Tests catch logic errors and edge cases you missed
- Fuzzing tries inputs you would never think of manually
- Static analysis spots patterns and vulnerabilities in seconds
- Together they form a safety net that catches 95% of common bugs
The remaining 5%? That's where audits and professional security review come in. But you shouldn't even need an auditor if you run this workflow consistently.
The Real Cost of Skipping This
I know someone who skipped fuzz testing. They shipped an ERC-20 token to mainnet that looked fine. A user found a bug, drained $500K in 10 minutes.
The bug? An integer overflow in a loop — exactly the kind of thing Foundry's fuzzer would have caught in 2 seconds.
They didn't have 2 seconds. They had $500K in losses.
Your Challenge
If you want real-world experience:
- Find a simple AI-generated contract (or use the one above)
- Install Foundry and Slither (5 minutes)
- Follow the 11-step workflow
- Note what each tool catches
- Fix the issues
- Re-run everything until green
When you're done, you've got:
- A production-ready contract
- A repeatable security workflow
- Real experience with professional tools
- The confidence to trust your own code
That's what separates hobbyists from professionals in Web3.
Key Takeaway
AI generates code fast. But "fast" and "safe" are different things.
The developers winning in Web3 aren't the ones writing the most code. They're the ones shipping the safest code. And that means running every contract through a gauntlet of automated tools before mainnet.
Today's workflow is what serious Ethereum teams run for every single contract. You now know exactly how to do it.
What's Coming Next
Today we walked the full path: dangerous code → automated testing → static analysis → fixes → verified safety.
Tomorrow, we shift gears completely. We've been living in the on-chain world (contracts, tests, deployment). But what about the data that lives off-chain?
Enter IPFS and Arweave — decentralized storage for files, metadata, and everything too big to fit on the blockchain. When you deploy an NFT, the image doesn't live on Ethereum. It lives somewhere else. Tomorrow, you'll learn where and why that matters.
Day 33: Why your NFT metadata doesn't live on Ethereum, and where it actually goes.
Resources to Go Deeper
Foundry Book — Testing :Complete guide to forge test, fuzzing, and invariants.
OpenZeppelin ReentrancyGuard: The industry-standard reentrancy lock; read and understand this pattern.
Slither GitHub: Static analyzer docs and source code.
ConsenSys Diligence — Smart Contract Best Practices: Classic reference for security patterns and common pitfalls.
Follow the series on Medium | Twitter | Future
Jump into Web3ForHumans on Telegram and let's build together.
Top comments (1)
I've been thinking about the security implications of AI-generated code, especially in a context as intricate as smart contracts. The point you mentioned about vulnerabilities in automated Solidity contracts is so real. I recall a project where I attempted to deploy an AI-generated contract, and it exposed potential security holes that could be easily exploited, which was a huge wake-up call for me. If I had documented my experience with that, it could be valuable for others facing the same challenges.