L06: Solidity Programming
Code That Handles Value: Why Solidity Demands Precision
01Writing software is normally a forgiving activity. Ship a bug, issue a patch, push a hotfix, roll back the database. The entire DevOps discipline exists to make mistakes recoverable. Solidity programming operates under a categorically different set of rules: once a contract is deployed to Ethereum mainnet, its bytecode is permanent. There is no update button. No call to support. No regulatory authority that will freeze the funds. If your withdrawal function has a reentrancy flaw, anyone who finds it can drain your contract until it is empty — and the transaction is valid on-chain.
This immutability is not a defect. It is the mechanism that makes trustless contracting possible. Counterparties can interact with a contract knowing its rules cannot be silently changed by the deployer. But the same property that makes contracts trustworthy makes errors catastrophic. The history of DeFi is littered with multimillion-dollar exploits tracing back to a single overlooked state update, a missing access control modifier, or an arithmetic assumption that stopped holding when inputs got large enough.
Solidity emerged from this crucible. Version 0.8.0 (released December 2020) introduced built-in arithmetic overflow checks — previously, integer overflow was a silent bug that attackers exploited routinely. Custom errors (0.8.4) reduced gas costs while improving debuggability. The language is actively evolving to make the common mistakes harder to make. But no compiler can protect against logic errors, and the most dangerous vulnerabilities in modern contracts are rarely syntactic. They arise from misunderstanding how the EVM execution model interacts with external contract calls.
Understanding why Solidity exists — and what it compiles to — is the foundation for writing it correctly. Solidity is not a scripting language bolted onto a blockchain. It is a statically typed, contract-oriented language that compiles to EVM (Ethereum Virtual Machine) bytecode. Every Solidity expression has a deterministic gas cost. Every state mutation is a persistent write to a global database shared by thousands of nodes. Every external call is a potential attack surface. Thinking in these terms, rather than thinking in the terms of conventional application programming, is the core skill this lecture develops.
Language Fundamentals: Contract Structure and the EVM Model
02A Solidity contract is the fundamental unit of deployment. It maps directly onto an EVM account with code and a dedicated persistent storage trie. The canonical structure proceeds in a fixed order: SPDX license identifier and pragma version declaration at the top, followed by imports, state variable declarations, event definitions, custom error definitions, modifier definitions, the constructor, and finally function definitions. This ordering is a convention enforced by style guides and auditors, not the compiler, but deviating from it reliably produces confusing code.
The pragma statement specifies which compiler versions are acceptable. pragma solidity ^0.8.20; accepts any 0.8.x version at or above 0.8.20, preventing accidental compilation with older versions that lack critical safety features. Using an exact version pin (pragma solidity 0.8.20;) is recommended for production deployments to guarantee identical bytecode across environments.
• Arithmetic overflow/underflow reverts automatically (no more SafeMath library needed)
• ABI coder v2 is the default (supports nested structs and arrays in external calls)
• User-defined value types allow strongly-typed wrappers around primitives (safer than bare
uint256)• Custom errors reduce calldata gas cost by ~50% versus revert strings
Solidity's type system distinguishes value types and reference types. Value types (uint, int, bool, address, bytes1–bytes32, enums) are always copied on assignment. Reference types (array, struct, mapping, bytes, string) require an explicit data location annotation: storage, memory, or calldata. Getting data locations wrong is one of the most frequent beginner errors — and one that the compiler catches immediately, unlike the runtime errors that make Solidity dangerous.
uint8 for token decimals. uint32 for Unix timestamps until 2106. uint256 for token amounts where overflow protection matters most. Smaller types cost the same gas individually but pack tighter in storage slots.
| Language | Paradigm | Safety Model | Target VM | Maturity |
|---|---|---|---|---|
| Solidity | Contract-OOP | Static typing + 0.8 checks | EVM | High — largest ecosystem |
| Vyper | Functional-imperative | No inheritance, no modifiers | EVM | Moderate — security-focused |
| Yul / Inline Assembly | Low-level IR | None — developer responsibility | EVM | Expert only |
| Fe | Rust-inspired | Rust-like borrow concepts | EVM | Early — production risk |
| Cairo | Functional | STARK proof constraints | StarkVM | Growing — StarkNet only |
Functions, Visibility, and Data Locations
03Every Solidity function carries a visibility specifier that determines who can call it. The four options reflect the trust hierarchy of the EVM: public functions are callable from anywhere and generate a getter for state variables; external functions can only be called from outside the contract (more gas-efficient for large argument arrays since calldata is not copied); internal functions are accessible only within the contract and its derived contracts; private functions are accessible only within the defining contract itself. The discipline of assigning the most restrictive visibility that still permits the intended use case is fundamental to access control.
State mutability modifiers complement visibility. A view function reads state but does not modify it, costing no gas when called externally (but still consuming gas when called from within a transaction). A pure function neither reads nor modifies state — it is a deterministic computation on its inputs. A payable function accepts ETH alongside the call. Functions without a mutability modifier can read and write state. Marking functions correctly is not just documentation: the compiler enforces these restrictions and some static analysis tools flag violations as potential bugs.
storage: Permanent, on-chain. Every SSTORE costs 20,000–22,000 gas (cold write). SLOAD costs 2,100 gas (cold read). State variables always live in storage.
memory: Temporary scratch space within a single transaction. Cleared after function returns. Required annotation for reference-type function parameters and return values in non-external functions.
calldata: Read-only slice of the transaction input data. Cheapest to read; cannot be modified. Use for external function parameters whenever possible.
Modifiers are reusable pre- and post-condition checks attached to functions with the modifier keyword. The _; placeholder marks where the modified function body executes. Modifiers are syntactic sugar for guard clauses — they improve readability and reduce repetition for patterns like onlyOwner, nonReentrant, and whenNotPaused. However, modifiers that perform complex logic or read external state can introduce unexpected gas costs and should be used judiciously.
Events are the primary mechanism for communicating state changes to off-chain consumers. When a Solidity event is emitted, the EVM writes a log entry to the transaction receipt — a structure containing topics (indexed parameters, up to three) and data (non-indexed parameters). Logs are not accessible from within the EVM (contracts cannot read their own events), but they are cheap relative to storage and form the backbone of how frontends, subgraphs, and analytics tools track on-chain activity. Emitting an event for every significant state transition is not optional best practice — it is the contract’s interface with the outside world.
Design Patterns: Inheritance, Interfaces, and CEI
04Solidity supports single and multiple inheritance. When a contract inherits from multiple parents that define the same function, the C3 linearization algorithm determines which implementation takes precedence — the same algorithm Python uses. Understanding the Method Resolution Order (MRO) is non-negotiable when working with the OpenZeppelin library, which uses deep inheritance trees extensively. The override and virtual keywords make the intended resolution explicit and mandatory, preventing silent shadowing bugs.
Interfaces define a contract’s public API without any implementation. They contain only external function signatures — no state variables, no constructors, no implementations. Any contract that implements all functions in an interface satisfies the interface type, enabling the composition pattern that powers DeFi. A lending protocol can interact with any ERC-20 token through the IERC20 interface without knowing the token’s specific implementation. This composability is not incidental — it is the architectural property that allows DeFi protocols to build on each other like software lego blocks.
1. Checks: Validate all preconditions with
require or custom errors. Reject invalid input immediately.2. Effects: Update all contract state before any external interaction. Record the transfer as complete before sending funds.
3. Interactions: Make external calls to other contracts or send ETH only after state is fully settled.
Violating this order is the root cause of every reentrancy attack. The DAO hack in 2016 sent ETH before updating the balance, allowing recursive withdrawal until the contract was drained.
Access Control Pattern
Restrict sensitive functions to authorized addresses using onlyOwner or role-based access via AccessControl. OpenZeppelin provides battle-tested implementations. Avoid single-owner models for high-value contracts — use multi-sig or timelocks.
Pull Payment (Withdrawal)
Instead of pushing ETH to recipients (which calls their receive function and opens reentrancy), record balances in a mapping and let recipients pull their funds. Separates accounting from delivery and eliminates the CEI violation risk entirely.
Circuit Breaker (Pause)
Add a paused state variable and whenNotPaused modifier to critical functions. Allows emergency suspension of contract activity if an exploit is detected. Governance can pause while a fix is prepared, buying time before funds are drained.
Proxy Upgrade Pattern
Deploy a thin proxy contract that delegates all calls to a separate implementation contract via delegatecall. Upgrading means pointing the proxy at a new implementation. Storage layout must be preserved across upgrades to avoid corruption.
Abstract contracts occupy the middle ground between concrete contracts and interfaces. They may contain both implemented and unimplemented functions. An abstract contract cannot be deployed directly — it must be inherited and all abstract functions overridden in the concrete subclass. This pattern is useful for providing default logic while enforcing that subclasses supply domain-specific implementations. OpenZeppelin’s ERC20 base contract uses this pattern: it provides the full ERC-20 implementation but marks _mint and _burn as internal, leaving token-issuance policy to the subclass.
Security Vulnerabilities: The Canonical Attack Taxonomy
05Smart contract security is its own subdiscipline. Unlike application security, where the attacker is constrained by network boundaries and authentication layers, on-chain attackers interact with contracts through the same public interface as legitimate users. Any function that is public or external is callable by anyone, including other contracts written to exploit it. The attack surface is the entire contract ABI.
receive() function that calls back into the victim. The attack sequence: (1) Attacker calls victim.withdraw(1 ETH). (2) Victim sends 1 ETH via call{value: 1 ether}(""). (3) This triggers attacker’s receive(). (4) Attacker immediately calls victim.withdraw(1 ETH) again. (5) Victim’s balance hasn’t been updated yet, so the check passes. (6) Loop repeats until victim is drained. Mitigation: CEI pattern or ReentrancyGuard mutex.
| Vulnerability | Root Cause | Historic Loss | Primary Mitigation |
|---|---|---|---|
| Reentrancy | External call before state update | $60M+ (DAO) | CEI pattern, ReentrancyGuard |
| Integer Overflow | Silent wrap-around in <0.8.x | $8M (BEC token) | Solidity 0.8+ built-in checks |
| Access Control | Missing onlyOwner / role checks | $320M (Wormhole) | AccessControl, multi-sig |
| Oracle Manipulation | On-chain price spot-reading | $182M (Beanstalk) | TWAP oracles, Chainlink |
| Flash Loan Attack | Unchecked atomicity assumption | $130M (Cream) | Cooldowns, TWAP, multi-block |
| Tx.origin Auth | Using tx.origin instead of msg.sender | Multiple | Always use msg.sender |
| Delegatecall Injection | Arbitrary delegatecall to user input | $280M (Parity) | Whitelist implementations |
The Parity multisig freeze of 2017 illustrates a subtler class of vulnerability. A library contract that provided wallet logic was itself a contract — and had no access control on its initialization function. A user called initWallet() on the library directly, became its owner, then called kill() to selfdestruct it. Every proxy wallet that depended on that library via delegatecall was instantly bricked. Over $280 million worth of ETH became permanently inaccessible — not stolen, just frozen. The upgrade pattern that was supposed to add flexibility became the single point of failure.
Gas Optimization: Writing Code That Users Can Afford
06Gas is the EVM’s unit of computational work. Every opcode has a fixed cost: addition costs 3 gas, a cold storage read costs 2,100 gas, a cold storage write costs 22,100 gas. These numbers are not incidental — they are calibrated to reflect the actual cost imposed on every Ethereum node that must execute and store the result. When a user pays 50 Gwei per gas for a simple token transfer, they are compensating every full node on the network for replicating that computation.
The most impactful optimizations target storage access because storage operations are by far the most expensive EVM operations. The general discipline is: read each storage slot at most once, cache the value in a local memory variable, perform all computation in memory, and write back at the end only if the value changed. Declare variables in the order they will be used together so the compiler can pack them into fewer 256-bit storage slots — two uint128 variables in adjacent declarations occupy a single slot versus two separate slots.
Calldata over memory for external function parameters: when a function is external and receives array or struct inputs, declaring them as calldata avoids an ABI decoding copy into memory. For functions processing large datasets, this can reduce gas cost by 30–60%.
Custom errors over revert strings: a revert string like "Insufficient balance" is stored as bytecode and included in transaction calldata when the revert occurs. A custom error like error InsufficientBalance(address user, uint256 needed); uses a 4-byte selector, costs significantly less in bytecode size, and carries typed parameters for better off-chain debugging.
uint128 a; uint128 b; = 1 slot. uint128 a; uint256 b; uint128 c; = 3 slots (b forces a new slot, then c forces another). Saving even one SSTORE on a frequently called function can reduce user costs by thousands of dollars at scale.
The EIP-2929 and EIP-2930 access lists formalized the distinction between cold and warm storage reads. The first access to a storage slot within a transaction costs 2,100 gas (cold); subsequent accesses within the same transaction cost only 100 gas (warm). This distinction rewards the caching pattern described above and penalises functions that read the same slot multiple times without caching.
Finally, the EtherRoum size limit — contracts above 24,576 bytes cannot be deployed. Large contracts must be split using the proxy pattern, libraries, or modular contract architecture. Solidity libraries with the library keyword can be deployed once and linked to multiple contracts, amortising the bytecode storage cost across all consumers. Internal library functions are inlined at compile time (no linking overhead), while external library functions require a delegatecall.
Tooling and Testing: The Professional Development Stack
07Solidity development has matured dramatically since the days of Truffle and Remix as the only available tools. The modern professional stack combines fast compilation, property-based fuzzing, mainnet forking for integration tests, and automated static analysis. Choosing the right toolchain affects how quickly you can iterate safely — and safe iteration is the distinguishing capability of production-grade contract development.
• Tests written in JavaScript/TypeScript using ethers.js
• Excellent plugin ecosystem (Etherscan verify, gas reporter, coverage)
• Hardhat Network: in-process EVM with
console.log in Solidity• Slower compilation than Foundry for large projects
• Familiar to web developers; large community
Best for: teams with JavaScript expertise, projects needing TypeScript SDKs
• Tests written in Solidity itself (no language context switch)
• Built-in fuzzer: automatically generates inputs to break invariants
•
forge test --fork-url: fork mainnet state for realistic integration tests• Gas reports, snapshot testing, cheatcodes (warp time, prank as any address)
• Significantly faster compilation and test execution
Best for: security-focused development, fuzz testing, protocol teams
The testing pyramid for smart contracts includes three layers. Unit tests isolate individual functions, mock external dependencies, and verify that each function behaves correctly given legal and illegal inputs including boundary values. Integration tests deploy multiple interacting contracts and verify that protocols compose correctly under realistic conditions, often using forked mainnet state so that real deployed contracts (Uniswap, Aave, Chainlink) are available as dependencies. Fuzz tests specify invariants — properties that must hold for all possible inputs — and let the fuzzer automatically search for counterexamples. A well-specified invariant suite is stronger than any set of hand-written test cases because it can discover edge cases the developer never imagined.
• Total supply never exceeds the cap defined at construction
• Sum of all balances always equals total supply (conservation)
• A non-approved address can never transfer tokens on behalf of another
• Approved allowance decreases by exactly the transferred amount after a transferFrom
• Balances never underflow (implicit in 0.8+, but worth expressing explicitly)
Static analysis tools operate on the contract AST or bytecode without executing it. Slither (Trail of Bits) detects reentrancy, unchecked return values, incorrect ERC compliance, and dozens of other patterns with high precision. Mythril uses symbolic execution to find paths that lead to violations of security properties. Echidna is a property-based fuzzer that works at the bytecode level. Running all three is standard practice before submitting to a formal audit — catching low-hanging fruit with automated tools makes the audit more productive and cheaper.
Error handling in Solidity has three mechanisms. require(condition, message) validates inputs and conditions, reverting with a string message on failure — simple but expensive in bytecode. revert CustomError(params) (Solidity 0.8.4+) reverts with a typed custom error, cheaper in both bytecode size and gas, and carries structured information useful for off-chain tooling. assert(condition) is reserved for internal invariants that should never fail — if an assert fires, it indicates a bug in the contract logic rather than bad user input, and it consumes all remaining gas (a strong signal to auditors and users that something went seriously wrong).
Deployment Reality: From Local to Mainnet
08The gap between a working local test suite and a mainnet-ready contract is significant. Deployment is not merely a technical step — it is a risk management decision. Once deployed, your contract is immutable (unless you have deliberately built in upgradeability, which trades immutability for administrative trust). Errors in constructor logic, access control misconfiguration, and incorrect initial state are all visible on-chain from block one and cannot be corrected.
The production deployment workflow has five mandatory stages. Local testing with Foundry or Hardhat covers unit and fuzz tests. Testnet staging (Sepolia or Holesky for Ethereum) exercises the contract under real network conditions with real transaction ordering, mempool dynamics, and EVM version parity. Formal audit by an independent security firm — typically 2–4 weeks for mid-sized protocols — surfaces issues the development team has normalised. Bug bounty extends the audit to the broader security research community for a defined reward schedule. Phased mainnet launch caps total value at risk during the initial period, reducing blast radius if a residual vulnerability is discovered.
Source code verification on Etherscan is the minimal transparency requirement for any public-facing contract. Verification uploads your Solidity source and compiler settings so Etherscan can reproduce the deployed bytecode. This allows users to read the exact code they are interacting with, not just a description. Unverified contracts are a red flag — they signal either inexperience or deliberate opacity, and sophisticated users will not interact with them.
The key takeaways from this lecture form a single coherent discipline. Solidity is a statically typed language that compiles to EVM bytecode; understanding what each construct costs in gas is part of using it correctly. Function visibility and data locations are access control and efficiency decisions, not just syntax. The Checks-Effects-Interactions pattern prevents the majority of reentrancy vulnerabilities. Gas optimization starts with storage access patterns. Testing means unit tests, integration tests with forked mainnet state, and fuzz tests over invariants — all three are necessary. Deployment to mainnet requires a professional audit, source verification, and a governance structure for the admin keys. None of these is optional if real value is at stake.