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:
- An
initialize()function (for upgradeable contracts) that has noinitializermodifier, so anyone can call it after deployment and become the owner. - Admin functions like
setFee,withdrawAllorsetOraclewritten withoutonlyOwnerbecause the prompt never mentioned permissions. - A custom modifier that checks the wrong thing, for example
require(owner != address(0))instead ofrequire(msg.sender == owner).
// 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
- Floating pragmas like
pragma solidity ^0.8.0;. Pin the exact compiler version you tested with. uncheckedblocks copied from gas-optimisation snippets. Solidity 0.8 checks overflow by default;uncheckedturns that off.- Unbounded loops over arrays that users can grow, which can make a function too expensive to ever call.
- Spot-price oracles: reading a price directly from a single DEX pool, which can be moved within one transaction using a flash loan.
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:
- Prefer audited libraries (OpenZeppelin) over hand-rolled token, access-control and math code.
- Tell the assistant explicitly: "follow checks-effects-interactions, use SafeERC20, add access control to every admin function".
- Run static analysis on every version you intend to deploy, not just the first one.
- 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.
- 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.