Future

Cover image for Why Your AI-Generated Smart Contract Will Get Hacked (And How to Fix It)
Ribhav
Ribhav

Posted on • Originally published at Medium

Why Your AI-Generated Smart Contract Will Get Hacked (And How to Fix It)

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:

  1. Reentrancy blindness — AI will happily write code that sends funds before updating balances (Day 30's lesson). It doesn't know this is dangerous.

  2. Missing access controls — Functions that should be restricted to the owner get shipped as public because the AI was trained on too many generic examples.

  3. 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
Enter fullscreen mode Exit fullscreen mode

On Windows, use WSL2 (Windows Subsystem for Linux) or download the installer from getfoundry.sh.

Verify installation:

forge --version
cast --version
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Verify:

slither --version
Enter fullscreen mode Exit fullscreen mode

You should see Slither 0.10.0+ or newer.

Set Up Your Project:

forge init vault-security-demo
cd vault-security-demo
Enter fullscreen mode Exit fullscreen mode

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 {}
}
Enter fullscreen mode Exit fullscreen mode

Paste this into src/SimpleVault.sol and compile:

forge build
Enter fullscreen mode Exit fullscreen mode

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:

  1. Check: require(balances[msg.sender] >= amount, ...)
  2. Send ETH: msg.sender.call{value: amount}("")
  3. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it:

forge test -vv
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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");
    }
Enter fullscreen mode Exit fullscreen mode

Run fuzzing with 10,000 random inputs:

forge test --fuzz-runs 10000 -vv
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

Key improvements:

  1. ReentrancyGuard — Prevents reentrancy attacks
  2. Checks-Effects-Interactions pattern — State updates BEFORE external calls
  3. Input validation — Bounds checks on reward rate, amount > 0
  4. Events for everything — Deposit, withdraw, config changes all logged
  5. Ownable from OpenZeppelin — Standard access control
  6. nonReentrant modifier — Applied to all functions that transfer funds
  7. Better receive() — Rejects random ETH transfers

Compile the fixed version:

forge build
Enter fullscreen mode Exit fullscreen mode

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);
    }
}


Enter fullscreen mode Exit fullscreen mode

Run the complete test suite:


bash
forge test -vv


Enter fullscreen mode Exit fullscreen mode

Result: All tests pass. Now run Slither on the fixed contract:


bash
slither src/SimpleVault.sol


Enter fullscreen mode Exit fullscreen mode

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:

  1. Get AI-generated contract → Paste into Foundry
  2. Compileforge build
  3. Write unit tests → Happy paths + edge cases
  4. Run testsforge test
  5. Add fuzz tests → Let Foundry try 10,000 random inputs
  6. Run fuzz testsforge test --fuzz-runs 10000
  7. Run static analysisslither src/
  8. Read findings → Understand what each tool caught
  9. Fix issues → Apply patterns (CEI, ReentrancyGuard, validation)
  10. Re-test + re-analyze → Loop until all green
  11. 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:

  1. Find a simple AI-generated contract (or use the one above)
  2. Install Foundry and Slither (5 minutes)
  3. Follow the 11-step workflow
  4. Note what each tool catches
  5. Fix the issues
  6. 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


Follow the series on Medium | Twitter | Future

Jump into Web3ForHumans on Telegram and let's build together.




Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
__edf47362fe5d7 profile image
크르릉이 (크르릉이)

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.