The Unforgettable Solidity Rules: Don't Mess Up Your First Smart Contract
Introduction: The Mistakes That Will Make You Cry
There are mistakes that hurt a little. And there are mistakes that cost millions of dollars.
In Solidity, the difference between:
- A secure contract
- A contract that loses all the money
…Sometimes is a single line of code.
This blog is what someone should have told you 10 minutes ago. The rules that if you follow them, you won’t get hacked. Not all of them, but the ones that kill.
RULE 1: msg.sender Is NOT What You Think
The Classic Error
// ❌ BAD - Assumes tx.origin is who calls
function withdraw() external {
require(tx.origin == owner); // TRAP!
payable(owner).transfer(address(this).balance);
}
Why is it bad?
tx.origin is the original address of the transaction, not who calls directly.
If:
- You (owner) call ContractA
- ContractA calls my ContractB
- ContractB does
require(tx.origin == owner)
It passes validation! Because tx.origin is still you.
Someone can trick you by doing:
// Malicious contract
function trap() external {
VulnerableContract.withdraw(); // Steals money!
}
The Unforgettable Rule:
NEVER use tx.origin for permission validation.
Use msg.sender.
// ✅ CORRECT
function withdraw() external {
require(msg.sender == owner); // Secure
payable(owner).transfer(address(this).balance);
}
Why it works:
msg.sender= who calls directly (in this code block)tx.origin= where the tx originally came from
Remember: If someone tells you to use tx.origin, run. Now.
RULE 2: call() vs transfer() - Use Call (But Be Careful)
The Old Problem
// ❌ OLD (Solidity 0.5)
function withdraw() external {
require(balances[msg.sender] >= amount);
msg.sender.transfer(amount); // ❌ Limited gas, can fail
balances[msg.sender] -= amount;
}
Why transfer() is archaic:
- Only passes 2300 gas
- If receiver is a contract that rejects, it fails
- If receiver needs more gas to process, it fails
- Will probably fail for no obvious reason
The Modern Version (But Dangerous)
// ⚠️ MODERN BUT DANGEROUS
function withdraw() external {
require(balances[msg.sender] >= amount);
(bool success, ) = msg.sender.call{value: amount}(""); // ❌ Reentrancy!
require(success);
balances[msg.sender] -= amount; // The vulnerability is here
}
What’s the problem?
The balance is reduced AFTER sending money. An attacker can do:
receive() external payable {
if(address(targetContract).balance > 0) {
targetContract.withdraw(); // Call again!
}
}
And drain everything.
The Correct Solution (Checks-Effects-Interactions)
// ✅ CORRECT - CEI Pattern
function withdraw(uint256 amount) external {
// CHECKS: We verify preconditions
require(balances[msg.sender] >= amount);
// EFFECTS: We modify state first
balances[msg.sender] -= amount;
// INTERACTIONS: Then we talk to external contracts
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
The Unforgettable Rule:
Checks → Effects → Interactions (CEI)
- Verify everything first (
require) - Modify state (
balances[msg.sender] -= amount) - Then you talk to other contracts (
call())
If you don’t respect this order, reentrancy will kill you.
RULE 3: Increments and Decrements - += 1 Is NOT Free
The Novice Error
// ❌ Inefficient
counter++; // 5200 gas
counter = counter + 1; // 5200 gas
The More Efficient Version
// ✅ More gas-efficient
unchecked {
counter++; // 100 gas (Solidity 0.8+)
}
Why the difference?
In Solidity 0.8+, every ++ comes with overflow protection.
counter++; // It's like:
// counter = counter + 1;
// assert(counter <= type(uint256).max); // ← This line costs gas
If you know it won’t overflow, you put it in unchecked:
unchecked {
counter++; // No protection, but we know it won't overflow
}
When to use it?
// ✅ Safe, counter will never reach 2^256
unchecked {
for (uint256 i = 0; i < 10; i++) {
doSomething();
}
}
// ❌ Dangerous, user could add huge things
unchecked {
balances[msg.sender] += userInput; // DON'T DO THIS!
}
The Unforgettable Rule:
Use unchecked only if you KNOW it won’t overflow. Otherwise, leave it as is.
The gas saved isn’t worth it if you lose $1M to overflow.
RULE 4: require() vs assert() vs revert() - Use Require
The Confusion
require(condition, "Message"); // ❌ When do I use it?
assert(condition); // ❌ Difference?
revert("Error"); // ❌ And this?
The Clear Rules
require() = To validate user input
// ✅ CORRECT
function withdraw(uint256 amount) external {
require(amount > 0, "Amount must be greater than 0");
require(balances[msg.sender] >= amount, "Insufficient balance");
// ... rest of code
}
Use require for:
- Validating inputs
- Verifying preconditions
- Validating states the user controls
assert() = To validate things that should NEVER fail
// ✅ Correct use of assert
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
assert(balances[msg.sender] + amount == old_balance); // ← Never fails
}
revert() = For complex logic or custom errors
// ✅ Modern and gas-efficient
error InsufficientBalance(uint256 available, uint256 required);
function withdraw(uint256 amount) external {
if (balances[msg.sender] < amount) {
revert InsufficientBalance(balances[msg.sender], amount);
}
}
The Unforgettable Rule:
- User input →
require() - Internal invariant →
assert() - Custom error →
revert()with custom error
If you use assert() to validate user input, you’re doing it wrong.
RULE 5: Contract Balance Is NOT “Your Property”
The Classic Error
// ❌ DANGEROUS
function claimRewards() external {
uint256 reward = calculateReward();
payable(msg.sender).transfer(address(this).balance); // ALL THE MONEY!
}
What’s wrong?
You just transferred ALL the contract’s money, not just the rewards.
If there are 100 ETH in the contract and you only deserved 1 ETH, you just stole 99.
Real Example That Happened
A staking contract had:
- 1000 users with 100 ETH each
- Total: 100,000 ETH
The admin writes:
function claimAll() external onlyAdmin {
payable(msg.sender).transfer(address(this).balance); // Transfer EVERYTHING
}
Result? Admin steals 100,000 ETH by accident (or on purpose).
The Correct Solution
Keep a record of who has the right to what:
// ✅ CORRECT
mapping(address => uint256) balances;
function deposit() external payable {
balances[msg.sender] += msg.value; // Record how much they have
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
The Unforgettable Rule:
NEVER use address(this).balance to determine how much to send.
Use mappings. Always.
// ❌ BAD
uint256 toSend = address(this).balance;
// ✅ GOOD
uint256 toSend = balances[msg.sender];
RULE 6: Events Are NOT Storage
The Novice Error
// ❌ BAD - You think emitting event saves data
event Transfer(address indexed from, address indexed to, uint256 amount);
function transfer(address to, uint256 amount) external {
// ... validations
emit Transfer(msg.sender, to, amount); // You think this saved?
// NOPE! It just emitted an event
}
What happened?
- You emitted the event
- The blockchain recorded it
- But the balance never changed
The Uncomfortable Truth
Events are logs, not storage.
// ❌ INCOMPLETE
function transfer(address to, uint256 amount) external {
emit Transfer(msg.sender, to, amount);
// What about balances? They weren't updated!
}
// ✅ CORRECT
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount; // ← Here's the magic
balances[to] += amount; // ← Here too
emit Transfer(msg.sender, to, amount); // ← Event is just for notification
}
The Unforgettable Rule:
Events are notifications, not storage.
- State changes =
mappingorstorage - Notifications =
event
RULE 7: The Order of Validations Matters
The Subtle Error
// ❌ BAD ORDER
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount; // What if to is address(0)?
balances[to] += amount;
require(to != address(0), "Don't send to address(0)"); // Too late
}
What happened?
You already modified state before validating. Someone sent money to address(0) and lost it.
The Correct Order
// ✅ CORRECT ORDER
function transfer(address to, uint256 amount) external {
// 1. All validations FIRST
require(to != address(0), "Don't send to address(0)");
require(amount > 0, "Amount must be > 0");
require(balances[msg.sender] >= amount, "Insufficient balance");
// 2. Then modify state
balances[msg.sender] -= amount;
balances[to] += amount;
// 3. Emit events
emit Transfer(msg.sender, to, amount);
}
The Unforgettable Rule:
Checks → Effects → Interactions (CEI) always.
Validations first, ALWAYS.
RULE 8: address(0) Is Your Enemy
The Classic Error
// ❌ BAD - Someone sends to address(0)
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount;
balances[to] += amount; // If to == address(0), money disappears
emit Transfer(msg.sender, to, amount);
}
Why It Matters
address(0) is:
- The “black hole” of Ethereum
- Money that goes there disappears forever
- It’s where burned tokens go (deliberately)
The Solution
// ✅ CORRECT
function transfer(address to, uint256 amount) external {
require(to != address(0), "Can't send to address(0)");
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
balances[to] += amount;
emit Transfer(msg.sender, to, amount);
}
Places Where address(0) Kills
// ❌ Dangerous
oracle = address(0); // Now all oracle calls fail
// ❌ Dangerous
owner = address(0); // Contract without owner!
// ❌ Dangerous
validator = address(0); // Invalid transactions
// ✅ Correct - Always validate
require(newOwner != address(0), "Don't assign address(0) as owner");
The Unforgettable Rule:
Validate that addresses are NOT address(0) in setters.
function setOwner(address newOwner) external onlyOwner {
require(newOwner != address(0)); // Always
owner = newOwner;
}
RULE 9: Numeric Types Matter (uint256 vs uint8)
The Error
// ❌ DANGEROUS
uint8 counter = 255;
counter++; // Overflow! counter is now 0
The Reality
Each type has limits:
uint8: 0 to 255uint16: 0 to 65535uint32: 0 to 4,294,967,295uint256: 0 to 115792089237316195423570985008687907853269984665640564039457584007913129639935
Common Errors
// ❌ BAD - Assume timestamp never overflows
uint32 timestamp = block.timestamp; // In 2106, it fails!
// ✅ CORRECT
uint256 timestamp = block.timestamp; // uint256 is safe
// ❌ BAD - Assume amount fits in uint8
uint8 amount = 300; // Silent overflow!
// ✅ CORRECT
uint256 amount = 300; // uint256 is default for a reason
The Unforgettable Rule:
Use uint256 by default.
Use other types only if:
- There’s a specific reason (use less storage)
- You really know it won’t overflow
- You have it documented
// ✅ Reasonable
uint128 x; // Explanation: We know x never exceeds 2^128
// ❌ Risky
uint8 amount; // We assume amount is always < 256?
RULE 10: Don’t Trust External Inputs
The Dangerous Error
// ❌ DANGEROUS - You trust oracle price
function swapWithPrice(uint256 price) external {
require(price > 0);
// ... you do a swap based on that price
}
What can happen?
Someone sends price = 1 and you sell 1000 ETH for 1 wei.
The Solution
Always validate input range:
// ✅ CORRECT
function swapWithPrice(uint256 price) external {
require(price > 0, "Price must be > 0");
require(price < 10000 ether, "Price can't be that high"); // Realistic limit
require(price >= getOraclePrice() * 95 / 100, "Price is too low"); // Within range
// ... do the swap
}
Another Example: Arrays
// ❌ BAD - What if user sends 1000 items?
function processArray(uint256[] calldata items) external {
for (uint256 i = 0; i < items.length; i++) {
// Unlimited gas, can fail
}
}
// ✅ CORRECT
function processArray(uint256[] calldata items) external {
require(items.length <= 100, "Maximum 100 items");
for (uint256 i = 0; i < items.length; i++) {
// Bounded gas
}
}
The Unforgettable Rule:
Never trust external inputs. Validate minimum and maximum limits.
require(input >= min, "input too low");
require(input <= max, "input too high");
RULE 11: public vs external vs internal - Nuance
The Novice Error
// ❌ EVERYTHING is public
function transfer(address to, uint256 amount) public {
// Someone can call this directly
}
function _internalHelper() public { // Why is this public?
// This should be internal
}
The Difference
external // Only from outside, more gas-efficient with calldata
public // From outside AND inside, more flexible
internal // Only from inside, can't be called directly
private // Only in this contract, NOT in inheritors
Real Example
// ✅ CORRECT
function transfer(address to, uint256 amount) external { // Others call it
_executeTransfer(msg.sender, to, amount);
}
function _executeTransfer(address from, address to, uint256 amount) internal { // Internal only
require(balances[from] >= amount);
balances[from] -= amount;
balances[to] += amount;
}
The Unforgettable Rule:
- external: For other contracts/users
- internal: Helper, no public exposure
- public: Only if it really needs to be public
RULE 12: Inheritors Can Break Your Logic
The Dangerous Error
// ❌ BAD - Someone inherits and redefines
contract Parent {
function withdraw() external {
// Critical security logic
}
}
contract Child is Parent {
function withdraw() external override {
// Here I can change EVERYTHING
// Reentrancy, theft, etc.
}
}
The Solution
Use virtual and override consciously:
// ✅ CORRECT - Explicitly virtual
contract Parent {
function withdraw() external virtual {
// ...
}
function _internalLogic() internal {
// If not virtual, can't be overridden
}
}
The Unforgettable Rule:
By default, don’t make functions virtual. Only if you need them to be overridden.
// ❌ Dangerous - Open without reason
function criticalFunction() public virtual {
}
// ✅ Safe - Explicitly virtual
function customBehavior() public virtual {
// This IS designed to be overridden
}
// ✅ Safe - Not virtual
function criticalSecurity() public {
// This CAN'T be touched
}
RULE 13: Gas Is Real, Limits Too
The Classic Error
// ❌ BAD - Attacker sends 10,000 items
function processAll(uint256[] calldata items) external {
for (uint256 i = 0; i < items.length; i++) {
doExpensiveOperation();
}
}
What happened?
- The tx costs more gas than exists in a block (30M gas limit)
- The tx fails
- Money is lost
The Solution
Bound loops:
// ✅ CORRECT
function processAll(uint256[] calldata items) external {
require(items.length <= 100, "Max 100 items"); // Bounded
for (uint256 i = 0; i < items.length; i++) {
doExpensiveOperation();
}
}
Another Strategy: Paginator
// ✅ GOOD - Allow processing in batches
function processInBatches(
uint256[] calldata items,
uint256 start,
uint256 end
) external {
require(start < end && end <= items.length);
require(end - start <= 100, "Max 100 per batch");
for (uint256 i = start; i < end; i++) {
doExpensiveOperation();
}
}
The Unforgettable Rule:
Loops are never infinite. They always have limits.
// ❌ Dangerous
for (uint256 i = 0; i < userInput; i++) {
}
// ✅ Safe
require(userInput <= 100);
for (uint256 i = 0; i < userInput; i++) {
}
RULE 14: Nonce and Replay Attacks
The Sophisticated Error
// ❌ DANGEROUS - Without nonce, same message can be used 2 times
function permitTransfer(
address to,
uint256 amount,
uint8 v,
bytes32 r,
bytes32 s
) external {
bytes32 message = keccak256(abi.encode(to, amount));
address signer = ecrecover(message, v, r, s);
require(signer == owner);
// Someone can use the same signature again!
}
The Solution
Use nonce:
// ✅ CORRECT - With nonce
mapping(address => uint256) nonces;
function permitTransfer(
address to,
uint256 amount,
uint256 nonce, // ← Here it is
uint8 v,
bytes32 r,
bytes32 s
) external {
require(nonce == nonces[owner], "Invalid nonce");
bytes32 message = keccak256(abi.encode(to, amount, nonce));
address signer = ecrecover(message, v, r, s);
require(signer == owner);
nonces[owner]++; // Increment after
// ... process transfer
}
The Unforgettable Rule:
Always use nonce in signatures and cryptographic operations.
Without nonce, the same message can be reproduced indefinitely.
RULE 15: Test As If You Want to Break It
The Mental Error
// ❌ Weak tests
function test_transfer() external {
transfer(user, 100);
assert(balances[user] == 100); // Only happy path test
}
The Correct Testing
// ✅ Strong tests
function test_transfer_happy_path() external {
transfer(user, 100);
assert(balances[user] == 100);
}
function test_transfer_insufficient_balance() external {
vm.expectRevert("Insufficient balance");
transfer(user, 10000); // More than you have
}
function test_transfer_to_zero_address() external {
vm.expectRevert("Cannot transfer to zero");
transfer(address(0), 100);
}
function test_transfer_reentrancy() external {
// Malicious contract tries reentrancy
ReentrancyAttacker attacker = new ReentrancyAttacker(this);
// Should fail
}
function test_transfer_zero_amount() external {
vm.expectRevert("Amount must be > 0");
transfer(user, 0);
}
The Unforgettable Rule:
For each function, write tests for:
- The happy case
- Error cases
- Edge cases (0, max, etc.)
- Malicious cases (attacks)
// Tests = (happy cases + 3*bad cases)
Final Checklist: Before Deploying
Before touching mainnet, answer “yes” to EVERYTHING:
- Have I used
msg.senderand NOTtx.origin? - Have I followed Checks → Effects → Interactions (CEI)?
- Have I validated that there’s NO
address(0)in critical places? - Have I bounded loops and arrays?
- Have I tested bad cases, not just happy cases?
- Did I use appropriate numeric types (preferably
uint256)? - Have I validated minimum and maximum inputs?
- Don’t I trust external data without validating?
- Is emitting events the last thing I do in a function?
- Have I used
require()to validate input? - Have I used Slither to detect obvious vulnerabilities?
- Have I documented WHY I do each validation?
If you answered “no” to any, go back.
Conclusion: The Right Mindset
Every line of code in Solidity:
- Potentially handles real money
- Could be attacked
- Could lose money
That’s why:
- It’s not paranoia to validate EVERYTHING
- It’s not over-engineering to follow CEI
- It’s not excess to test everything
It’s professionalism.
When you write your next contract, remember:
You’re not writing normal code.
You’re writing code that will handle real people’s money.
So don’t mess it up.
Follow these rules. And then, sleep peacefully.