ContractScan

Five bugs AI keeps writing into Solidity (and how to fix each one)

By ContractScan Team ·

AI coding assistants are very good at producing Solidity that compiles. Compiling is the easy part. A smart contract is a program that holds other people's money in public, forever, and anyone can call any external function with any input. The model writing your contract usually optimises for "looks like the examples I have seen", not "survives a motivated attacker".

This article walks through five classes of bugs that show up again and again in AI-generated contracts, why they happen, and how to fix each one. None of them are exotic. They are all well documented, and static analysers such as Slither and Aderyn detect most of them automatically.

1. Reentrancy: sending money before updating state

The classic. A withdraw function sends ETH (or calls a token with hooks) and only afterwards updates the user's balance. The recipient can be a contract whose receive() function calls withdraw() again, before the balance was zeroed.

// Vulnerable
function withdraw() external {
    uint256 amount = balances[msg.sender];
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "send failed");
    balances[msg.sender] = 0;          // too late
}

Models write this because the "natural" order of the sentence is "send the money, then reset the balance". The fix is the checks-effects-interactions pattern: validate, update your own state, and only then talk to the outside world. Add a reentrancy guard for defence in depth.

// Fixed
function withdraw() external nonReentrant {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "nothing to withdraw");
    balances[msg.sender] = 0;          // effect first
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "send failed");
}

We cover this bug in depth in Reentrancy explained for vibe coders.

2. Missing or broken access control

Ask a model for "an ERC20 with a mint function" and you will sometimes get exactly that: a mint function anyone can call. Other variants of the same mistake:

// Vulnerable: anyone can print tokens
function mint(address to, uint256 amount) external {
    _mint(to, amount);
}

// Fixed (OpenZeppelin Ownable)
function mint(address to, uint256 amount) external onlyOwner {
    _mint(to, amount);
}

A good habit: list every external and public function and write down, for each one, who should be allowed to call it. If the answer is not "anyone", there must be a check in the code.

3. Using tx.origin for authorisation

tx.origin is the externally owned account that started the transaction. msg.sender is the immediate caller. Older tutorials, which models have seen a lot of, sometimes use tx.origin for owner checks.

// Vulnerable
function transferOwnership(address newOwner) external {
    require(tx.origin == owner, "not owner");
    owner = newOwner;
}

The problem: if the owner is tricked into calling any malicious contract (an airdrop claim, a fake mint page), that contract can call transferOwnership and the check still passes, because tx.origin is still the owner. Always use msg.sender for authorisation.

4. Ignoring return values of external calls and token transfers

Low-level calls (call, send, delegatecall) do not revert on failure; they return false. Some ERC20 tokens also return false instead of reverting, and some (notably older USDT deployments) do not return a value at all. AI code often does this:

// Vulnerable: failure is silently ignored
payable(user).send(amount);
token.transfer(user, amount);

If the transfer fails, your contract carries on as if the user was paid, and the accounting is now wrong. Fixes:

(bool ok, ) = payable(user).call{value: amount}("");
require(ok, "ETH transfer failed");

// For ERC20, use OpenZeppelin SafeERC20
using SafeERC20 for IERC20;
token.safeTransfer(user, amount);

5. Predictable "randomness"

Lotteries, NFT trait reveals and games generated by AI frequently use something like this:

// Vulnerable: miners/validators and other contracts can predict this
uint256 winner = uint256(keccak256(abi.encodePacked(
    block.timestamp, block.prevrandao, msg.sender
))) % players.length;

Every input here is either public or known to the block producer before the transaction is final. A contract can compute the same value in the same block and only participate when it wins. On-chain values are not a secure source of randomness for anything with value attached. Use a verifiable randomness service (for example Chainlink VRF) or a commit-reveal scheme, and design so that no single party can choose to abort after seeing the result.

Honourable mentions

Why AI keeps writing these

Language models reproduce patterns from their training data, and a lot of public Solidity is tutorials, old code and toy examples that were never meant for mainnet. The model also does not know your threat model unless you spell it out. "Write a staking contract" does not say "assume every caller is hostile", so the output often isn't written that way.

Practical habits that help:

  1. Prefer audited libraries (OpenZeppelin) over hand-rolled token, access-control and math code.
  2. Tell the assistant explicitly: "follow checks-effects-interactions, use SafeERC20, add access control to every admin function".
  3. Run static analysis on every version you intend to deploy, not just the first one.
  4. Write tests that act like an attacker: call admin functions from a random address, re-enter from a malicious receiver, pass zero and max values.
  5. For anything holding real value, get a professional audit.

Check your own contract

Paste a single-file Solidity contract into ContractScan for a free static-analysis check with plain-language explanations.

ContractScan is automated static analysis, not an audit. For contracts holding real value, get a professional audit.

Get new articles and product updates

Privacy: we store only your email, the signup time and the page you signed up from. No tracking, never sold or shared. Unsubscribe any time by emailing support@contract-scan.dev. Full policy: /privacy.