🌱 Beginner ⏱️ 30 min

Functions & Visibility

Functions are where the action happens — and where the vast majority of audit findings live. Missing access control, incorrect modifiers, selector collisions, and unprotected initializers are consistently in the top 10 vulnerability categories. This lesson covers every aspect of Solidity functions with complete exploit examples.

Function Anatomy — Every Keyword Explained

📋 Full Function Signature Breakdown
// Complete function signature with all optional keywords: function functionName( uint256 param1, address calldata param2 // storage location for reference types ) external // visibility: who can call this payable // state mutability: accepts ETH onlyOwner // custom modifier nonReentrant // another modifier returns (uint256 result) // return type (can be named) { // function body } // Minimal function (pure utility): function add(uint256 a, uint256 b) internal pure returns (uint256) { return a + b; }

Visibility Modifiers — Full Comparison Table

VisibilityCalled From Outside?Called Internally?Inherited Contracts?Gas (External Call)
publicYesYesYesStandard
externalYesNo (must use this.f())YesSlightly cheaper (calldata not copied)
internalNoYesYesN/A (not externally callable)
privateNoYes (only this contract)NoN/A (not externally callable)
🔒 Visibility Demonstration
contract VisibilityDemo { // public: callable by anyone (internal or external) function publicFunc() public pure returns (string memory) { return "anyone can call me"; } // external: only callable via external tx or call (cheaper for large calldata) function externalFunc() external pure returns (string memory) { // Cannot call internalFunc() directly here — must be from outside return "only from outside"; } // internal: only this contract and contracts that inherit from it function internalFunc() internal pure returns (uint256) { return 42; } // private: only THIS contract — not even child contracts function privateFunc() private pure returns (uint256) { return 99; } function callBoth() external view returns (uint256) { // OK — can call internal and private from within same contract return internalFunc() + privateFunc(); } } contract Child is VisibilityDemo { function test() external pure returns (uint256) { return internalFunc(); // OK — internal is inherited // return privateFunc(); — ERROR: private is NOT inherited } }

State Mutability — pure, view, payable, nonpayable

MutabilityReads State?Writes State?Receives ETH?Gas (External Call)
pureNoNoNoFree if called off-chain
viewYesNoNoFree if called off-chain
(nonpayable)YesYesNo (reverts if ETH sent)Standard gas
payableYesYesYesStandard gas
📖 Free Calls: view and pure Functions

view and pure functions cost no gas when called from off-chain (e.g., a frontend calling them via eth_call). They DO cost gas when called from within a transaction (on-chain call from another contract). This distinction matters for auditing: if a view function is called within a write transaction, it still consumes gas.

Function Modifiers — Custom Access Control

🔒 Writing and Using Modifiers
contract ModifierDemo { address public owner; bool public paused; mapping(address => bool) public isAdmin; // MODIFIER: Runs code before (and optionally after) the function // The _; is where the function body executes modifier onlyOwner() { require(msg.sender == owner, "Not owner"); _; // Function body runs here } modifier whenNotPaused() { require(!paused, "Contract is paused"); _; } // Modifier with parameter: modifier onlyRole(address requiredRole) { require(isAdmin[msg.sender], "Missing role"); _; } // Multiple modifiers: execute LEFT to RIGHT // onlyOwner runs first, then whenNotPaused, then function body function pause() external onlyOwner whenNotPaused { paused = true; } // Modifier with code AFTER the function body (guards on exit): modifier checkInvariant() { _; // Function body runs first // Then this check runs AFTER: require(address(this).balance > 0, "Balance invariant violated"); } }

Constructor — Initialization Bugs

🏗️ Constructor Patterns and Pitfalls
contract ConstructorDemo { address public owner; uint256 public immutable createdAt; // Constructor runs ONCE at deployment — code is NOT stored on-chain constructor(address _owner) { require(_owner != address(0), "Invalid owner"); // Critical check owner = _owner; createdAt = block.timestamp; } } // HISTORICAL BUG: Parity Multisig constructor naming // In old Solidity, constructors were named the same as the contract // If the contract was renamed but constructor wasn't → it became public! // Old vulnerable pattern (Solidity < 0.4.22): contract WalletLibrary { // Developer renamed the contract but forgot to rename "constructor" // initWallet() is now a PUBLIC function anyone can call! // Attacker calls initWallet() after deployment → becomes new owner function initWallet(address[] memory owners) public { owner = msg.sender; // Anyone can become owner! $150M frozen } } // PROXY PATTERN BUG: initialize() replaces constructor // With proxy contracts, constructors don't run — you must call initialize() // If initialize() has no onlyOwner check and no check for re-initialization: contract VulnerableProxy { bool private initialized; address public owner; // VULNERABLE: No initialization guard function initialize(address _owner) external { owner = _owner; // Anyone can call this and become owner! } // SECURE: Add initialization guard function initializeSecure(address _owner) external { require(!initialized, "Already initialized"); initialized = true; owner = _owner; } }

Fallback and Receive Functions

📥 receive() and fallback() Explained
// receive(): Called when ETH is sent with no calldata // fallback(): Called when no function matches the selector, or when receive() doesn't exist contract FallbackDemo { event Received(address sender, uint256 value, bytes data); // Called when: ETH sent + empty calldata (plain ETH transfer) receive() external payable { emit Received(msg.sender, msg.value, ""); } // Called when: function selector doesn't match any function // OR: ETH sent with non-empty calldata and no receive() fallback() external payable { emit Received(msg.sender, msg.value, msg.data); } } // Decision tree for incoming call to a contract: // // Is msg.data empty? // YES → Does receive() exist? YES → call receive() // NO → call fallback() (if payable) // NO → Does a function match msg.sig? YES → call that function // NO → call fallback() // (if it exists, else revert) // SECURITY: Unexpected ETH via selfdestruct or coinbase // A contract CAN receive ETH without any function being called: // 1. selfdestruct(address(victim)) — forces ETH into victim, no receive() called // 2. coinbase reward — if a validator lists contract as coinbase // Invariants like require(address(this).balance == something) can break!

Function Selectors and Selector Clashes

🔏 Function Selectors — 4-Byte Identifiers
// Function selector = first 4 bytes of keccak256("functionName(types)") // This is how the EVM knows which function to call // Computing selectors: keccak256("transfer(address,uint256)") = 0xa9059cbb... // First 4 bytes: 0xa9059cbb — this is the ERC-20 transfer selector // SELECTOR COLLISION: Two functions with different names can have the same selector! // keccak256 of any two different strings can produce the same first 4 bytes. // Probability: ~1 in 4 billion — but with intentional crafting, easily found. // Example: These two functions have the SAME selector (intentionally crafted) function transfer(address, uint256) external { } // 0xa9059cbb function gasprice_bit_ether(int128) external { } // Also 0xa9059cbb! // ATTACK VECTOR: Malicious proxy implementation // A transparent proxy routes calls via delegatecall based on selectors // If attacker inserts a malicious function with the same selector as an admin function: // → Admin calls upgradeTo() on proxy // → Proxy routes to malicious function with same selector // → Attacker controls the upgrade logic // This is why transparent proxy pattern keeps admin-only functions in the proxy itself // Tool to find collisions: https://sig.eth.samczsun.com/ // Auditors check for selector clashes between proxy and implementation

Return Values — Single, Multiple, Named

🔙 Return Value Patterns
// Single return value: function getOne() external pure returns (uint256) { return 42; } // Multiple return values (tuple): function getMultiple() external pure returns (uint256, bool, address) { return (42, true, address(0)); } // Named return values (can assign directly, return is optional): function getNamed() external pure returns (uint256 amount, bool success) { amount = 42; success = true; // No explicit return needed — named returns are automatically returned } // SECURITY: Unchecked return values! // External calls return (bool success, bytes memory data) // If you don't check success, failed calls are silently ignored (bool ok, ) = addr.call{value: amount}(""); require(ok, "Transfer failed"); // ALWAYS check this // ALSO DANGEROUS: ERC-20 transfer() returns bool on some tokens, reverts on others // Always use OpenZeppelin's SafeERC20 for token transfers: using SafeERC20 for IERC20; token.safeTransfer(to, amount); // Handles both revert and bool=false

Access Control Patterns

🔑 Access Control — From Simple to Role-Based
// PATTERN 1: Simple Ownable (single admin) import "@openzeppelin/contracts/access/Ownable.sol"; contract SimpleAccess is Ownable { constructor() Ownable(msg.sender) {} function adminOnly() external onlyOwner { // Only the owner can call this } } // PATTERN 2: Role-Based Access Control (RBAC) import "@openzeppelin/contracts/access/AccessControl.sol"; contract RoleBasedAccess is AccessControl { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); constructor() { _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); _grantRole(MINTER_ROLE, msg.sender); } function mint() external onlyRole(MINTER_ROLE) { // Only accounts with MINTER_ROLE can call this } } // PATTERN 3: Custom modifier with mapping contract CustomAccess { mapping(address => bool) public operators; modifier onlyOperator() { require(operators[msg.sender], "Not an operator"); _; } }

Common Vulnerabilities — Full Exploit Examples

1. Missing Access Control

💥 Exploit: Anyone Can Call Admin Functions
// VULNERABLE: Critical function missing access control contract VulnerableVault { address public owner; mapping(address => uint256) public balances; // BUG: Anyone can call this — no onlyOwner modifier! function withdrawAll(address payable to) external { to.transfer(address(this).balance); } // Attacker calls: vault.withdrawAll(attackerAddress) → drains vault } // SECURE: function withdrawAll(address payable to) external onlyOwner { to.transfer(address(this).balance); }

2. Modifier Logic Error

💥 Exploit: Modifier That Always Passes
// BUG: The check is there but it's wrong — never reverts modifier brokenCheck() { if (msg.sender != owner) { // Developer forgot the revert/require — just does nothing on failure! } _; } // BUG: Missing underscore in wrong place (modifier never executes function) modifier badModifier() { require(msg.sender == owner); // No _; — the function body NEVER runs! } // BUG: Modifier checks wrong variable address admin; address owner; modifier onlyAdmin() { require(msg.sender == owner); // WRONG: should be admin _; }

3. Unprotected initialize() Function

🚨 Critical: Unprotected Proxy Initializer

The Parity multisig bug ($150M frozen) and numerous proxy hacks all stem from an unprotected initialization function. When using upgradeable proxy patterns, the implementation contract's constructor does not run — you must call initialize(). If that function has no re-initialization guard, anyone can call it and take ownership.

💥 Unprotected Initializer — Full Exploit
// VULNERABLE: Implementation contract with unprotected initializer contract VulnerableImplementation { address public owner; uint256 public totalSupply; // No guard — anyone can call this AFTER deployment and take ownership function initialize() external { owner = msg.sender; // Attacker becomes owner totalSupply = 1e9; } } // SECURE: Using OpenZeppelin Initializable import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; contract SecureImplementation is Initializable { address public owner; // initializer modifier: can only be called ONCE, reverts otherwise function initialize(address _owner) external initializer { owner = _owner; } }
✏️ Try It — Write a Contract with Proper Access Control
// Deploy this in Remix and practice calling functions from different addresses pragma solidity ^0.8.20; contract AccessControlPractice { address public owner; bool public paused; mapping(address => uint256) public balances; modifier onlyOwner() { require(msg.sender == owner, "Not owner"); _; } modifier whenNotPaused() { require(!paused, "Paused"); _; } constructor() { owner = msg.sender; } function deposit() external payable whenNotPaused { balances[msg.sender] += msg.value; } function pause() external onlyOwner { paused = true; } function unpause() external onlyOwner { paused = false; } // Practice: Try calling pause() from a non-owner address // Try calling deposit() when paused // Try calling deposit() from two different accounts }

Common Mistakes Section

⚠️ Mistake #1: External Functions Calling Internal Unsafely

When an external function needs to be called internally, you must use this.functionName(). This creates an actual external call — which costs extra gas and, critically, can change msg.sender to the contract's own address. Don't do this. Instead, extract the logic into an internal function that both the external function and internal callers can share.

⚠️ Mistake #2: Not Checking Return Values of Low-Level Calls

addr.call{value: x}("") does NOT revert on failure — it returns (bool success, bytes memory data). If you don't check success, a failed ETH transfer is silently ignored. This pattern has caused real fund losses. Always check: require(success, "Transfer failed").

⚠️ Mistake #3: Forgetting to Protect the initialize() Function

With proxy patterns, the constructor does not run on the implementation contract. Developers must add an initializer modifier (from OpenZeppelin's Initializable) or equivalent guard to prevent re-initialization. Forgetting this is one of the most common critical findings in proxy contract audits.

Summary / Key Takeaways

ConceptKey RuleSecurity Impact
public vs externalexternal is cheaper for large calldataBoth are callable by anyone
privateOnly this contract — not inheritedStorage still readable by nodes
pure/viewCannot modify stateFree to call off-chain
payableRequired to receive ETHMissing = ETH sends revert
ModifierRuns before/after functionAccess control backbone
constructor()Runs once, not storedInitialization bugs = permanent
initialize()Proxy pattern constructor substituteMust be guarded or anyone can call
Function selector4-byte keccak256 of signatureCollisions enable selector attacks
receive()Called on plain ETH sendsUnexpected ETH can break invariants