Lecture 4: Solidity I: Basics
Instructor: Yu Feng, UCSB
CS190: Blockchain Programming and Applications
Welcome to Lecture 4, where we'll explore Solidity—the primary programming language for Ethereum smart contracts. This session covers fundamental concepts that form the foundation of blockchain development, from basic syntax to secure Ether handling patterns.
Why Solidity?
Solidity serves as a crucial bridge between human-readable code and the Ethereum Virtual Machine's low-level bytecode. Rather than writing raw EVM opcodes, developers can express complex logic in a high-level language that compiles down to efficient machine instructions.
The language enables three core capabilities that make Ethereum programmable: persistent state management across transactions, programmatic rules that execute automatically, and secure value transfer without intermediaries.
Solidity is uniquely designed with blockchain constraints in mind—emphasizing determinism (same inputs always produce same outputs), transparency (code visible to all), and cost-awareness (every operation consumes gas).
1
Solidity Source
Human-readable code
2
Compiler
Translation layer
3
Bytecode
EVM instructions
4
EVM Execution
On-chain runtime
Smart Contracts in Ethereum
A smart contract is more than just code—it's a combination of executable bytecode and persistent data stored at a specific blockchain address. Once deployed, the bytecode becomes immutable, ensuring that the contract's core logic cannot be altered. Every interaction with the contract is transparent and recorded on the blockchain for anyone to audit.
Immutable Code
Bytecode deployed once, unchangeable logic ensures predictability and trust
Persistent Storage
State variables stored in contract's dedicated storage root, maintained across blocks
Transparent Interactions
All function calls and state changes visible on-chain, fully auditable history
The state for each contract is stored in its own dedicated storage root within the Ethereum state trie. While the code itself is immutable, developers can implement upgradability through the proxy pattern—using delegatecall to route execution to new logic contracts while preserving the original storage.
Minimal Contract Skeleton
Every Solidity contract follows a standard structure that includes three essential components. Let's examine a minimal but complete contract to understand each part:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.26; contract SimpleStorage { // state variables and functions here }
SPDX License Identifier
The first line specifies the software license. This machine-readable tag helps with license compliance and is required by the compiler. MIT is commonly used for open-source projects.
Pragma Directive
Declares which compiler version(s) can compile this code. The ^0.8.26 syntax means "version 0.8.26 or higher, but below 0.9.0" ensuring compatibility while preventing breaking changes.
Contract Block
The main container for your smart contract code. Everything between the curly braces defines the contract's state variables, functions, and logic. Keep initial contracts minimal to isolate and debug errors easily.
State Variables & Persistence
State variables are the backbone of smart contract persistence—they're stored permanently in the blockchain's contract storage. Unlike local variables that exist only during function execution, state variables maintain their values across all transactions.
contract SimpleStorage { uint256 public storedData; }
The public visibility modifier automatically generates a getter function, allowing anyone to read the value without writing additional code. This is a powerful feature for transparency.
Reading State
Cheap—accessing storage in view functions costs minimal gas
Writing State
Expensive—modifying storage is one of the costliest EVM operations
Off-Chain Reads
Free—RPC calls to view functions cost zero gas, enabling efficient data queries
Understanding the cost difference between reads and writes is crucial for writing efficient contracts. Each storage slot (32 bytes) consumes significant gas when modified, making it essential to minimize unnecessary writes and batch updates when possible.
Data Locations: storage, memory, calldata
Solidity requires explicit data locations for reference types like arrays and structs. Each location has distinct characteristics that affect both gas costs and behavior. Choosing the right location is critical for contract efficiency and correctness.
The key insight: calldata is most efficient for input data, memory for temporary computation, and storage only when persistence is required. Avoid unnecessary copies between locations to minimize gas costs.
Example: Copy vs Reference
Understanding the difference between copying and referencing data is fundamental to writing correct Solidity code. The wrong choice can lead to unexpected behavior or wasted gas.
function setAge(uint256 newAge) public { // Creates a storage reference - modifies on-chain state Profile storage ref = profiles[msg.sender]; ref.age = newAge; // Creates a memory copy - changes lost after function ends Profile memory copy = profiles[msg.sender]; copy.age = newAge; // This change is NOT persisted! }
1
Storage → Memory (Copy)
Creates a complete clone of the data in temporary memory. Modifications don't affect the original. Higher gas cost due to copying.
2
Storage Reference
Creates a pointer to the actual on-chain data. Modifications directly update the blockchain state. More efficient for updates.
3
Calldata (No Copy)
Best for large input arrays—data remains in transaction input, zero copy overhead. Read-only but maximally efficient.
Best practice: Use storage references when you need to modify state variables, memory for temporary computations, and calldata for external function parameters (especially arrays). The compiler will catch many mistakes, but understanding the semantics prevents subtle bugs.
Core Types: Value & Reference
Solidity provides a rich type system divided into two categories: value types (copied by value) and reference types (passed by reference). Understanding this distinction is crucial for managing gas costs and preventing unexpected behavior.
Value Types
uint256 public a; // Unsigned integer uint8 public b; // Smaller integer bool public c; // Boolean address public d; // Ethereum address:160bits
Value types are stored directly in their location (stack or storage). When passed to functions or assigned, they're copied completely. The uint256 type matches the EVM's native 256-bit word size, making it the most gas-efficient for most operations.
Reference Types
struct User { string name; bool isRegistered; } mapping(address => User) public users; string[] public names;
Reference types include structs, arrays, and mappings. They're accessed via references rather than copies. Mappings are especially powerful—they provide O(1) key-value lookup but are non-iterable (you can't list all keys).
Execution Context
Every transaction executes within a rich context that provides information about the caller, the transaction, and the current blockchain state. Solidity exposes this context through global variables that are essential for implementing business logic, access control, and payment handling.
msg.sender
The address that directly called the current function. This is the most important variable for access control—use it to verify who's executing an action. Note that in delegate calls, msg.sender remains the original caller, not the intermediate contract.
msg.value
The amount of Ether (in wei) sent along with the transaction. Only accessible in payable functions. One ether = 10^18 wei. Use this to track deposits and implement payment logic.
block.timestamp
The Unix timestamp (seconds since Jan 1, 1970) of the current block. Useful for implementing deadlines and time-based logic. Warning: miners can manipulate this by ±15 seconds, so don't rely on it for precise timing in high-stakes applications.
These context variables are fundamental building blocks. Combine them to implement features like: timed auctions (block.timestamp), payment processing (msg.value), ownership patterns (msg.sender), and much more.
Function Visibility Specifiers
Visibility modifiers control who can call a function and how it can be invoked. Choosing the right visibility is critical for security—overly permissive functions create attack vectors, while overly restrictive ones limit legitimate use cases.
Security principle: Start with the most restrictive visibility (private/internal) and only increase it when necessary. Remember that "private" doesn't hide data from blockchain observers—all contract storage is publicly readable. It only restricts function call permissions.
Mutability & Payability
Function mutability modifiers declare whether a function can modify state, providing both compiler-enforced guarantees and gas optimizations. Understanding these distinctions is essential for writing efficient and predictable smart contracts.
// State-changing function - can modify storage function write(uint256 v) public { storedData = v; // Modifies state } // Read-only function - cannot modify state function read() public view returns (uint256) { return storedData; // Only reads state } // Pure computation - no state access at all function add(uint a, uint b) public pure returns (uint) { return a + b; // Pure logic, no blockchain interaction }
Default (Modifying)
Can read and write state variables. Costs gas when called in transactions. No restrictions on operations.
view
Can read state but cannot modify it. Free when called externally via RPC. Compiler prevents state modifications.
pure
Cannot read or write state. Mathematical computations only. Most restrictive but also most predictable and testable.
payable
Special modifier: function can receive Ether. Required for any function where msg.value > 0. Without it, Ether transfers fail.
Gas implications: View and pure functions cost zero gas when called externally (via eth_call), but they do consume gas when called by other contracts. Use view/pure liberally—they document intent and enable compiler optimizations.
Modifiers and Errors
Modifiers are reusable code blocks that execute before (and optionally after) a function, providing a clean way to implement preconditions, access control, and validation logic. They reduce code duplication and make security checks explicit and auditable.
modifier onlyOwner() { require( msg.sender == owner, "caller is not owner" ); _; // Function body executes here } function withdraw() public onlyOwner { // This function can only be called by owner // The modifier check runs first }
The underscore (_) represents where the function body executes. Code before the underscore runs as a precondition, while code after runs as a postcondition. You can use multiple modifiers on a single function—they execute in the order declared.
Modifier Executed
Precondition check runs first
Validation
require() evaluates condition
Function Body
Main logic executes at _
Success or Revert
Transaction completes or rolls back
Error handling: The require() statement is Solidity's primary validation mechanism. If the condition is false, the transaction reverts, all state changes are undone, and unused gas is refunded. The error message provides debugging information. Modern Solidity also supports custom errors (more gas-efficient) and revert() for manual rollbacks.
Payable Functions
Payable functions are the gateway for Ether to enter a smart contract. Without the payable modifier, any attempt to send Ether to a function will fail, regardless of the function's logic. This is a critical safety feature that prevents accidental loss of funds.
contract SimpleWallet { mapping(address => uint) public balances; function deposit() public payable { // msg.value contains the Ether sent with transaction balances[msg.sender] += msg.value; } function getBalance() public view returns (uint) { return balances[msg.sender]; } }
1
User initiates transaction
EOA calls deposit() with value=1 ETH
2
Function receives Ether
msg.value contains 1000000000000000000 wei
3
Balance updated
Mapping records sender's deposit in storage
4
Transaction confirmed
State change persisted to blockchain
Best practices: Always validate msg.value if your function has minimum deposit requirements. Track balances in a mapping rather than relying solely on the contract's total balance (address(this).balance), as this prevents accounting errors and enables proper per-user tracking.
Withdraw Pattern
The withdraw pattern follows the Checks-Effects-Interactions principle—a critical security pattern that prevents reentrancy attacks, one of the most dangerous vulnerabilities in smart contracts. Always update state before transferring Ether.
function withdraw(uint amount) public { // CHECKS: Verify preconditions require(balances[msg.sender] >= amount, "Insufficient balance"); // EFFECTS: Update state BEFORE external call balances[msg.sender] -= amount; // INTERACTIONS: External call comes last (bool ok,) = payable(msg.sender).call{value: amount}(""); require(ok, "Transfer failed"); }
01
Checks
Validate all preconditions first—balance sufficient, caller authorized, etc.
02
Effects
Update all state variables BEFORE any external interactions—this is the critical reentrancy defense
03
Interactions
Make external calls last, after state is secured—use call() for Ether transfers, not deprecated transfer()
Why this matters: If you transfer Ether before updating balances, a malicious contract can re-enter your withdraw function before the balance is decremented, draining your contract. The infamous DAO hack exploited exactly this vulnerability. Modern best practice: use call{value:...} instead of the now-deprecated transfer() or send() methods, as they're more flexible and won't break with EVM gas cost changes.
Receive & Fallback
Contracts can have two special functions for handling Ether transfers and unknown function calls. These functions don't have the function keyword and serve as catch-all handlers for specific scenarios.
receive() function
receive() external payable { // Called when Ether is sent with empty calldata emit Received(msg.sender, msg.value); }
The receive() function is triggered when someone sends Ether to the contract with no function selector (plain transfer). It must be external payable and cannot take parameters or return values. This is the preferred way to accept direct Ether transfers.
fallback() function
fallback() external payable { // Called when: // 1. Function selector doesn't match any function // 2. Ether sent but no receive() exists }
The fallback() function is more general—it triggers when the called function doesn't exist OR when Ether is sent without a receive() function. It's the ultimate catch-all. Can be payable or non-payable depending on whether you want to accept Ether in this case.
Important: These functions have limited gas when called via transfer() or send() (2300 gas), so keep their logic minimal. Complex operations should use dedicated payable functions instead. Always emit events in these functions to log activity.
Example: EtherWallet
Let's bring together everything we've learned into a complete, practical example. This EtherWallet contract demonstrates state management, access control, Ether handling, and safe withdrawal patterns in a cohesive implementation.
contract EtherWallet { address payable public owner; constructor() { owner = payable(msg.sender); } receive() external payable { // Accept Ether deposits } function withdraw(uint amount) external { require(msg.sender == owner, "Only owner can withdraw"); require(address(this).balance >= amount, "Insufficient balance"); (bool success,) = owner.call{value: amount}(""); require(success, "Transfer failed"); } function getBalance() external view returns (uint) { return address(this).balance; } }
This contract combines all our key concepts: the constructor sets immutable ownership, receive() enables deposits, withdraw() implements secure Ether transfer with access control, and getBalance() provides read-only state access. It's a foundation you can extend with additional features like multi-signature authorization or spending limits.
Example: EtherWallet
Let's bring together everything we've learned into a complete, practical example. This EtherWallet contract demonstrates state management, access control, Ether handling, and safe withdrawal patterns in a cohesive implementation.
State Persistence
Owner address stored permanently in contract storage
Access Control
Only owner can withdraw—enforced by require check
Ether Receive
receive() function accepts plain Ether transfers
Safe Withdrawal
Checks-effects-interactions pattern prevents reentrancy
View Function
Zero-cost balance query using view modifier
This contract combines all our key concepts: the constructor sets immutable ownership, receive() enables deposits, withdraw() implements secure Ether transfer with access control, and getBalance() provides read-only state access. It's a foundation you can extend with additional features like multi-signature authorization or spending limits.
Key Takeaways
Solidity bridges human logic to EVM execution
The language abstracts complex bytecode operations into readable syntax, but understanding the underlying EVM model (gas costs, storage, stack) makes you a better developer.
Data locations determine cost and behavior
Storage is persistent but expensive, memory is temporary and cheaper, calldata is read-only and cheapest. Choose wisely based on your needs—every location decision impacts gas costs.
Visibility and mutability protect your contracts
Use the principle of least privilege—start restrictive (private/internal, view/pure) and only open access when necessary. These modifiers aren't just documentation; they're security boundaries enforced by the compiler.
Safe Ether handling requires discipline
Always follow Checks-Effects-Interactions: validate inputs, update state, then interact externally. Use payable only when required, prefer call{value:...} over transfer, and never forget to check return values.
Mastering these fundamentals is your foundation for building secure, efficient smart contracts. Every vulnerability, every gas optimization, every design pattern builds on these core concepts.
Preview: Next Lecture
Solidity II: Contracts in Practice
Now that we understand Solidity basics, we'll explore advanced patterns that enable real-world applications. The next lecture covers four critical topics for professional smart contract development.
Inheritance
Code reuse through contract hierarchies and abstract contracts
Events
Logging mechanism for off-chain applications and debugging
Libraries
Reusable code modules and gas-efficient deployment patterns
Interfaces
Contract interactions and the foundation for DeFi composability
Try to implement a complete ERC20 token—the standard for fungible tokens on Ethereum. This hands-on exercise combines everything from this lecture with new concepts to create a production-ready token contract.
Topics include: transfer logic, allowances, events, supply management, and OpenZeppelin patterns.

Preparation: Review today's material and experiment with the EtherWallet example. Try extending it with additional features before the next session.