3.8.1 Solidity Language Pitfalls
Solidity behaves slightly differently than most developers expect coming from other languages, and the differences are where bugs live. A developer fluent in JavaScript, Rust, or Go can write Solidity that compiles cleanly and reverts at runtime — or worse, doesn't revert at all and silently produces wrong results.
This section covers the language-level traps: cases where the syntax looks reasonable, the compiler is satisfied, and the contract is still wrong. None of these are reentrancy or oracle manipulation or any of the protocol-level vulnerabilities; they are the mistakes that happen because Solidity is its own language with its own rules.
The pitfalls in this section are organized roughly by frequency in production bugs. Variable shadowing leads the list because it is both common and easily overlooked. Fallback/receive confusion is more subtle but produces some of the most expensive bugs. Initialization, visibility, and immutability round out the list with the bugs that catch even experienced developers off-guard.
Each pitfall follows the section template: what it is, vulnerable example, fixed example, and where applicable a Foundry test or tooling note.
Variable Shadowing
A local variable, parameter, or inherited state variable can have the same name as a state variable in the contract. The inner-scoped name takes precedence in expressions; references to the outer name need explicit disambiguation. The compiler emits a warning (since 0.5.0 — earlier versions silently accepted shadowing) but does not error.
Vulnerable Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Vault {
uint256 public balance;
function deposit(uint256 balance) external payable {
// The parameter `balance` shadows the state variable.
// This line modifies the local parameter, not the state.
balance = msg.value;
// The state variable `balance` is unchanged.
}
function getBalance() external view returns (uint256) {
return balance; // still 0
}
}
The developer almost certainly intended to update the state variable. The function compiles, runs without reverting, and reports success — but the state is never modified. A test that deposits 1 ETH and then reads balance will return 0, which may or may not be caught depending on how thoroughly the contract is tested.
A subtler form happens in inheritance:
contract Base {
uint256 internal _value;
}
contract Derived is Base {
uint256 internal _value; // shadows Base._value
function setValue(uint256 v) external {
_value = v; // sets Derived._value, not Base._value
}
}
This silently breaks code in Base that depends on _value being updated. The compiler warns about this case (since 0.6.0) but the warning is easy to miss in a noisy build output.
Fixed Example
Use distinct names for parameters and state variables. Common conventions:
contract Vault {
uint256 public balance;
// Underscore-prefixed parameter to avoid shadow
function deposit(uint256 _amount) external payable {
balance += _amount;
}
}
Or use a new prefix for "the value being set":
function setBalance(uint256 newBalance) external {
balance = newBalance;
}
For inheritance, avoid redeclaring inherited state variables. If a derived contract needs its own value, give it a distinct name.
Tooling
- Solhint flags shadowing with the
no-shadow-stateandno-shadow-pseudo-globalsrules. Enable in CI. - Slither has the
shadowing-state,shadowing-abstract,shadowing-builtin, andshadowing-localdetectors. The first three are typically findings; the last is informational but worth reviewing. - The Solidity compiler has emitted shadowing warnings since 0.5.0. Treat compiler warnings as errors in production builds (
solc --warn-mutability-onlyflag does not exist; use solhint or CI-level checks).
Cross-reference: Section 3.7.7 (Anti-Patterns Catalog) for the quick-reference version.
Fallback and Receive Confusion
A contract can receive ETH in two ways: plain transfers with no calldata (handled by receive()), and calls with calldata that don't match any function selector (handled by fallback()). Developers who don't internalize this distinction write contracts that behave unexpectedly when receiving funds.
The Selection Rules
The EVM dispatch follows these rules in order:
- If calldata length is 0 and
receive()exists →receive()runs - If calldata length is 0 and
receive()does not exist →fallback()runs (if it's payable) - If calldata length > 0 and no matching function selector →
fallback()runs - Otherwise → revert
The rules have edge cases. A contract with only fallback() external (not payable) reverts plain ETH transfers. A contract with only receive() (no fallback()) reverts calls with unrecognized selectors. A contract with neither cannot receive ETH at all and cannot accept calls to unrecognized selectors.
Vulnerable Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract DonationCollector {
address public owner;
uint256 public totalDonations;
event Donation(address from, uint256 amount, string note);
constructor() {
owner = msg.sender;
}
// The developer wants to log every donation with a note.
fallback() external payable {
string memory note = abi.decode(msg.data, (string));
totalDonations += msg.value;
emit Donation(msg.sender, msg.value, note);
}
}
Two bugs:
-
No
receive()function. A plain ETH transfer (no calldata) does not have a string to decode. The fallback runs withmsg.dataof length 0, andabi.decode("", (string))reverts. Plain transfers fail, defeating the purpose of a donation collector. -
No length check on calldata. Even when
msg.datahas some bytes, those bytes may not be a valid ABI-encoded string. The decode reverts and the donation is rejected.
Fixed Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract DonationCollector {
address public owner;
uint256 public totalDonations;
event Donation(address from, uint256 amount, string note);
constructor() {
owner = msg.sender;
}
// Plain ETH transfers (no calldata)
receive() external payable {
totalDonations += msg.value;
emit Donation(msg.sender, msg.value, "");
}
// Calls with calldata that doesn't match any function
fallback() external payable {
// Try to decode a note; ignore the result if decode fails
string memory note;
if (msg.data.length >= 64) {
try this.decodeNote(msg.data) returns (string memory n) {
note = n;
} catch {}
}
totalDonations += msg.value;
emit Donation(msg.sender, msg.value, note);
}
function decodeNote(bytes calldata data) external pure returns (string memory) {
return abi.decode(data, (string));
}
}
The fix distinguishes the two reception paths. Plain transfers go through receive() with an empty note. Calls with calldata attempt to decode the note but tolerate malformed input via a try/catch around the decode.
A more conservative approach: reject malformed input rather than tolerating it.
fallback() external payable {
string memory note = abi.decode(msg.data, (string)); // reverts on malformed
totalDonations += msg.value;
emit Donation(msg.sender, msg.value, note);
}
The choice depends on whether the contract should accept "best effort" donations or require well-formed inputs.
The "Cannot Receive ETH" Trap
A common related bug: a contract intended to not receive ETH (e.g., a logic contract behind a proxy) still receives it because of selfdestruct from another contract, mining rewards in pre-Merge testing, or other forced-funds mechanics. There is no way to refuse incoming selfdestruct ether — the value lands in the contract's balance unconditionally. Code that assumes address(this).balance == sum_of_tracked_balances will be wrong if any selfdestruct lands ETH at the contract address.
A common pattern in pre-Merge Ethereum was for an attacker to force-feed ETH into a contract via selfdestruct to break invariants. Post-merge, the same issue persists for SELFDESTRUCT opcodes that already exist (and after EIP-6780, selfdestruct only frees up the account when called in the same transaction as deployment).
The fix is the same regardless of era: never compute logical balances from address(this).balance. Track the contract's "logical" balance in a state variable updated on each operation.
uint256 public totalDeposits; // logical balance
function deposit() external payable {
totalDeposits += msg.value;
}
function withdraw(uint256 amount) external {
// Use totalDeposits, not address(this).balance
require(totalDeposits >= amount);
totalDeposits -= amount;
payable(msg.sender).transfer(amount);
}
Cross-reference: Section 3.7.7 (Anti-Patterns Catalog) for the quick reference; Section 4.11 covers detection during audit.
Visibility Defaults and Function Visibility Errors
Solidity functions and state variables have four visibility levels: public, external, internal, private. Two specific traps recur:
The Visibility-Default Trap (Historical)
Before Solidity 0.5.0, functions without an explicit visibility specifier defaulted to public. This produced the Parity multi-sig bug indirectly — functions intended to be internal helpers were callable by anyone. The compiler emits warnings for omitted visibility since 0.5.0; in 0.7.0+, omitting visibility on functions is an error.
For state variables, the default is still internal, which is the safer of the visibility levels. But "internal" does not mean "secret" — see the storage-secrecy note below.
The "private Means Secret" Misconception
// MISLEADING — the value is NOT secret
contract Auction {
uint256 private reservePrice;
constructor(uint256 _reservePrice) {
reservePrice = _reservePrice;
}
}
The private keyword controls access from other contracts and via the contract's external ABI. It does not control visibility on-chain. Anyone can read reservePrice from storage using eth_getStorageAt by computing the slot index from the variable's position.
// JavaScript (ethers v6) — reads the "private" reservePrice
const value = await provider.getStorage(auctionAddress, 0);
console.log("reservePrice:", BigInt(value));
There are no secrets on a public blockchain. The private visibility modifier provides API encapsulation, not data secrecy.
Fixed Example
If a value must remain hidden until reveal, use a commit-reveal pattern (Section 3.7.4) to store only a hash on-chain. If the value is sensitive but doesn't need to be cryptographically hidden, accept that it is observable but rely on its irrelevance to attackers.
contract SealedAuction {
bytes32 private commitment; // hash of (reservePrice, salt)
constructor(bytes32 _commitment) {
commitment = _commitment;
}
// Reveal phase
function reveal(uint256 reservePrice, bytes32 salt) external {
require(keccak256(abi.encode(reservePrice, salt)) == commitment, "bad reveal");
// Now reservePrice can be used
}
}
Cross-reference: Section 3.7.4 (External Interaction Patterns) covers commit-reveal in depth.
external vs public
A function callable only from outside the contract should be external, not public. The compiler can optimize external functions by leaving arguments in calldata; public functions copy arguments to memory unconditionally. For large argument payloads, the difference is significant.
// Suboptimal: arguments copied to memory even when called externally
function process(uint256[] memory data) public { /* ... */ }
// Better: arguments stay in calldata when called externally
function process(uint256[] calldata data) external { /* ... */ }
The functional difference matters for security too: a public function widens the contract's API surface beyond what the developer intended.
Immutable and Constant Variables: Initialization Traps
Solidity has three kinds of "doesn't change after construction" state variables:
constant— value set at compile time; no storage, baked into bytecodeimmutable— value set in the constructor; no storage, baked into bytecode at deployment- A regular state variable with no setter — uses storage, but conceptually fixed if no function modifies it
The Immutable-in-Initializer Trap (Upgradeable Contracts)
Immutable variables are set at deployment, which works fine for normal contracts but breaks for upgradeable contracts. The implementation contract behind a proxy is deployed once; the proxy calls it via delegatecall after deployment. The immutable's value is set during the implementation's constructor, which is fine — but if a new implementation is deployed, its immutable could be different.
For upgradeable contracts using OpenZeppelin's pattern, do not use immutable variables for values that need to be initialized differently per-proxy. The pattern is to use a regular state variable, set in an initializer function with the initializer modifier.
// WRONG for upgradeable: each implementation has its own MAX_SUPPLY
contract TokenV1 {
uint256 public immutable MAX_SUPPLY;
constructor(uint256 _maxSupply) {
MAX_SUPPLY = _maxSupply;
}
}
// CORRECT for upgradeable: state variable, set in initializer
contract TokenV1Upgradeable is Initializable {
uint256 public maxSupply;
function initialize(uint256 _maxSupply) external initializer {
maxSupply = _maxSupply;
}
}
Note that OpenZeppelin v5 added the constant and immutable warnings specifically for upgradeable contracts. Older code may have this bug latent.
The Constructor-Argument Trap
Constants in Solidity are compile-time. They cannot be set per-deployment.
// WRONG: can't pass _admin to a constant
contract Vault {
address constant ADMIN = _admin; // compile error
}
For per-deployment values, use immutable:
contract Vault {
address public immutable ADMIN;
constructor(address _admin) {
ADMIN = _admin;
}
}
For values that are truly fixed at compile time and never change, constant saves gas (the value is baked into bytecode and never read from storage):
contract Vault {
uint256 public constant ONE_DAY = 86400;
uint256 public constant MAX_FEE_BPS = 1000;
}
Constructor vs Initializer
A constructor runs once at deployment and cannot be called again. An initializer is a regular function that appears to be a constructor — typically named initialize — and is used in upgradeable contracts where the actual constructor cannot set state because the proxy's storage is separate from the implementation's.
The Trap
A constructor and an initializer are not interchangeable. Using a constructor in an upgradeable context means the constructor runs against the implementation's storage, which is never accessed via the proxy. The state variables initialized by the constructor remain at their default values in the proxy's storage.
// WRONG for upgradeable
contract VaultUpgradeable {
address public owner;
constructor(address _owner) {
owner = _owner; // sets owner in implementation's storage, not proxy's
}
}
Deployed behind a proxy, this contract's owner is always address(0) because the proxy's storage was never touched.
Fixed Example
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract VaultUpgradeable is Initializable {
address public owner;
function initialize(address _owner) external initializer {
owner = _owner;
}
}
The initializer modifier ensures the function can only run once — a critical property, since otherwise anyone could call initialize after deployment and overwrite the owner. Section 3.5 covers the proxy deployment workflow that ensures initialize is called atomically.
The "Disable Initializers" Pattern
Even with the initializer modifier on the proxy's intended initialize, the implementation contract (the one behind the proxy) still has the function callable. An attacker who calls initialize on the implementation directly (not through the proxy) doesn't affect the proxy's storage, but they may be able to take ownership of the implementation itself, which has implications for things like proxy admin functions.
OpenZeppelin's pattern is to disable initializers on the implementation contract during its constructor:
contract VaultUpgradeable is Initializable {
address public owner;
constructor() {
_disableInitializers(); // prevents initialize() from being called on the implementation
}
function initialize(address _owner) external initializer {
owner = _owner;
}
}
This was the root cause of the Wormhole bridge incident's $325M loss in February 2022 — the implementation contract's initialize function was callable directly because _disableInitializers() was not used.
Cross-reference: Section 3.5 (Smart Contract Upgradeability); Section 3.10 covers the Wormhole incident in detail.
Implicit Type Conversions
Solidity performs implicit type conversions in some cases that look intuitive but are not always safe. The most consequential cases:
Signed-to-Unsigned Conversion
function debit(int256 amount) external {
require(amount > 0);
balance -= uint256(amount); // conversion of int256 to uint256
}
If amount is positive, the conversion is safe. But Solidity does not check the sign at the point of conversion — uint256(-1) produces type(uint256).max. The explicit require(amount > 0) catches this case, but if the check is omitted or weakened (e.g., require(amount >= 0)), the bug returns.
Address-to-Contract Conversion
contract MyContract {
IERC20 public token;
constructor(address _token) {
token = IERC20(_token); // no code check!
}
}
The conversion IERC20(_token) does not verify that _token actually implements ERC-20. The contract trusts that whatever is at _token will respond correctly to the interface's function selectors. If _token is an EOA (no code), calls to it succeed and return empty data, which Solidity interprets as zero for numeric return types — leading to silent failures rather than reverts.
Defense: verify the address has code at the point of conversion.
constructor(address _token) {
require(_token.code.length > 0, "not a contract");
token = IERC20(_token);
}
OpenZeppelin's Address.isContract does this and adds the caveat that contracts in their constructor have no code yet, so the check is incomplete during deployment — see Section 3.7.7 for the EOA-vs-contract anti-pattern.
Foundry Test for Implicit-Conversion Catches
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/MyContract.sol";
contract ConversionTest is Test {
function test_rejectEOA() public {
address eoa = makeAddr("alice");
vm.expectRevert("not a contract");
new MyContract(eoa);
}
function test_rejectNegativeDebit() public {
// ...
}
}
The pass-with-EOA case is the bug that often slips through manual testing. A test that explicitly creates an EOA and passes it as the constructor argument catches the missing code-length check.
Quick Reference
| Pitfall | What goes wrong | Defense |
|---|---|---|
| Variable shadowing | Inner-scoped name silently used; state never updated | Distinct names (underscore prefix or new prefix); Solhint no-shadow-state |
| Fallback/receive confusion | Plain transfers revert; calls with calldata fail to decode | Implement both receive() and fallback() with clear responsibility for each |
| "private" assumed secret | Storage readable by anyone via eth_getStorageAt | Use commit-reveal (3.7.4); don't store secrets on-chain |
| public-when-external | Wider API surface, higher gas | Use external for outside-only functions; internal for helpers |
| immutable in upgradeable | Implementation's value used, proxy's is ignored | Use state variables with initializer |
Missing initializer modifier | Anyone can reinitialize and take ownership | Apply OZ's Initializable; call _disableInitializers() in implementation constructor |
Missing _disableInitializers | Implementation can be initialized by attacker | Constructor in implementation calls _disableInitializers() |
| Implicit int→uint conversion | uint256(-1) becomes type(uint256).max | Explicit sign check before conversion |
| Implicit address→contract | EOA passed as IERC20 silently treats reads as 0 | Verify _token.code.length > 0 |
Using address(this).balance for accounting | selfdestruct can force-feed ETH and break invariants | Track logical balance in state variable |
Cross-References
- Patterns reference — Section 3.7 (Smart Contract Patterns) covers the constructive versions of these defenses
- Anti-patterns catalog — Section 3.7.7 has scannable one-liner entries for shadowing, fallback/receive, floating pragma, and assert misuse
- Upgradeability mechanics — Section 3.5 covers the proxy patterns and initializer workflow
- Storage and delegatecall — Section 3.8.9 covers the deeper interaction between storage layouts and
delegatecallthat makes the upgradeable-immutable trap dangerous - Real exploits — Section 3.10.2 (Parity Multi-sig) is the canonical case driven by Solidity language pitfalls (uninitialized contracts, delegatecall semantics)
- Auditor's view — Section 4.11 covers detection during code review for several of these pitfalls