July 5, 2025

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:

  1. You (owner) call ContractA
  2. ContractA calls my ContractB
  3. 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)

  1. Verify everything first (require)
  2. Modify state (balances[msg.sender] -= amount)
  3. 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 inputrequire()
  • Internal invariantassert()
  • Custom errorrevert() 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 = mapping or storage
  • 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 255
  • uint16: 0 to 65535
  • uint32: 0 to 4,294,967,295
  • uint256: 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.sender and NOT tx.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.