Security

Never Trust isContract: The Constructor Bypass

A common requirement in smart contract development is to restrict interactions to human users (Externally Owned Accounts, or EOAs) and ban other smart contracts. This is often done to prevent botting in NFT mints, block flashloan attacks, or simplify governance.
The standard tool for this job is checking the code size of the calling address. If the code size is greater than zero, it's a contract. If it's zero, it's an EOA.
This assumption is dangerously flawed. Relying on this check for security is a fundamental architectural error that advanced attackers routinely exploit.

The Illusion of the "Code Size" Check

The typical implementation looks like this, using inline assembly to query the Ethereum Virtual Machine (EVM):
// VULNERABLE: Do not use for security
function isContract(address account) internal view returns (bool) {
uint256 size;
assembly {
size := extcodesize(account)
}
return size > 0;
}
A developer might use this in a minting function: require(!isContract(msg.sender), "No bots allowed");.
This seems logical. An EOA has no code, so its size is 0. A deployed smart contract has code, so its size is > 0. The logic holds for deployed contracts.

The Constructor Exploit

The vulnerability lies in the lifecycle of an Ethereum smart contract.
When a contract is being deployed, its constructor function is executed. During this specific execution phase, the contract's runtime bytecode has not yet been stored on the blockchain.
At this precise moment, extcodesize(address(this)) returns 0.
An attacker can easily bypass your "EOA only" check by wrapping their attack logic inside the constructor of a malicious contract.
  1. Attacker writes a contract: The constructor calls your vulnerable function (e.g., the NFT mint).
  2. Attacker deploys the contract: The constructor executes.
  3. Your contract checks isContract: It sees the caller has a code size of 0 and allows the transaction to proceed, believing it's a human user.
  4. Attack succeeds: The malicious contract executes the restricted action.
  5. Deployment finishes: The code is stored, and only now does the address have a code size > 0. Too late.

The Solution: Acknowledge the Limitation

There is no foolproof way to distinguish an EOA from a contract that is currently in its constructor phase using only on-chain data.
If you absolutely must restrict calls to EOAs, the only reliable check is: require(msg.sender == tx.origin, "Contracts not allowed");
This ensures the direct caller (msg.sender) is the same account that initiated the transaction (tx.origin), which must be an EOA.
Warning: Using tx.origin for authentication is generally discouraged because it makes your contract vulnerable to phishing attacks. A user could be tricked into calling a malicious contract that then calls yours, passing the tx.origincheck.
Conclusion: Do not rely on isContract or code size checks for any security-critical logic. Assume any address could be a contract executing from its constructor. Design your protocol to be robust against contract interactions, rather than trying to futilely ban them.