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
// 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
| Visibility | Called From Outside? | Called Internally? | Inherited Contracts? | Gas (External Call) |
|---|---|---|---|---|
public | Yes | Yes | Yes | Standard |
external | Yes | No (must use this.f()) | Yes | Slightly cheaper (calldata not copied) |
internal | No | Yes | Yes | N/A (not externally callable) |
private | No | Yes (only this contract) | No | N/A (not externally callable) |
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
| Mutability | Reads State? | Writes State? | Receives ETH? | Gas (External Call) |
|---|---|---|---|---|
pure | No | No | No | Free if called off-chain |
view | Yes | No | No | Free if called off-chain |
| (nonpayable) | Yes | Yes | No (reverts if ETH sent) | Standard gas |
payable | Yes | Yes | Yes | Standard gas |
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
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
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(): 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 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 implementationReturn Values — Single, Multiple, Named
// 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=falseAccess Control Patterns
// 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
// 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
// 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
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.
// 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;
}
}// 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
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.
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").
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
| Concept | Key Rule | Security Impact |
|---|---|---|
| public vs external | external is cheaper for large calldata | Both are callable by anyone |
| private | Only this contract — not inherited | Storage still readable by nodes |
| pure/view | Cannot modify state | Free to call off-chain |
| payable | Required to receive ETH | Missing = ETH sends revert |
| Modifier | Runs before/after function | Access control backbone |
| constructor() | Runs once, not stored | Initialization bugs = permanent |
| initialize() | Proxy pattern constructor substitute | Must be guarded or anyone can call |
| Function selector | 4-byte keccak256 of signature | Collisions enable selector attacks |
| receive() | Called on plain ETH sends | Unexpected ETH can break invariants |