← Back to course
Lecture 06 · Smart Contract Development

L06: Solidity Programming

From Syntax to Secure Deployment
Solidity is the primary language for Ethereum smart contracts — and writing it well is harder than it looks. Unlike ordinary software, deployed contract code is immutable: bugs cannot be patched, and a single reentrancy flaw can drain millions in seconds. This lecture builds from language fundamentals through security patterns to professional deployment practice.
Level: BSc Year 2 Prerequisites: L05 Ethereum & Smart Contracts Slides: 34 extended Charts: 22 Sections: 8

Writing 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.

Historical scale of the problem: Smart contract exploits have cost the ecosystem over $5 billion since 2016. The DAO hack (2016, $60M), Parity multisig freeze (2017, $280M locked permanently), Ronin Bridge (2022, $625M stolen), and Euler Finance (2023, $197M) are all rooted in Solidity-level programming errors, not infrastructure failures.
DeFi Exploit Costs Over Time
Fig 6.1 — Cumulative value lost to smart contract exploits 2016–2024, by vulnerability class

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.

Solidity Compilation Pipeline
Fig 6.2 — Solidity compilation pipeline: source to EVM bytecode and ABI
Solidity Developer Growth
Fig 6.3 — Solidity developer ecosystem growth 2016–2024: monthly active developers and deployed contracts

A 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.

Contract Structure
Fig 6.4 — Anatomy of a Solidity contract: the standard layout and relationships between components

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.

Version 0.8+ safety guarantees you rely on:
• 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, bytes1bytes32, 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.

Type sizing discipline: Use the smallest integer type that fits your domain. 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.
Solidity Data Types
Fig 6.5 — Solidity type system: value types, reference types, and mapping semantics
LanguageParadigmSafety ModelTarget VMMaturity
SolidityContract-OOPStatic typing + 0.8 checksEVMHigh — largest ecosystem
VyperFunctional-imperativeNo inheritance, no modifiersEVMModerate — security-focused
Yul / Inline AssemblyLow-level IRNone — developer responsibilityEVMExpert only
FeRust-inspiredRust-like borrow conceptsEVMEarly — production risk
CairoFunctionalSTARK proof constraintsStarkVMGrowing — StarkNet only
Smart Contract Language Evolution
Fig 6.6 — Smart contract language ecosystem: deployment share and relative feature maturity

Every 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.

Function Visibility
Fig 6.7 — Solidity function visibility model: call permission matrix across contract boundaries

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.

Data location rules — the three contexts:
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.
// Optimised: calldata avoids copy function processArray( uint256[] calldata data ) external view returns (uint256 total) { for (uint i; i < data.length; ++i) { total += data[i]; } }
// Storage cache pattern uint256[] public values; // storage function sumCached() external view returns (uint256 total) { uint256[] memory cached = values; for (uint i; i < cached.length; ++i) total += cached[i]; }

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.

Modifiers and Events
Fig 6.8 — Modifiers and events: guard patterns and the off-chain notification mechanism

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.

Solidity 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.

Solidity Inheritance
Fig 6.9 — Solidity inheritance: C3 linearization, virtual/override mechanics, and the OpenZeppelin hierarchy

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.

Checks-Effects-Interactions (CEI) — the most important pattern in Solidity:
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.
Common Solidity Patterns
Fig 6.10 — Common Solidity patterns: access control, withdrawal, pause, and proxy upgrade

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.

Smart 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.

Security Vulnerabilities
Fig 6.11 — Smart contract vulnerability taxonomy: frequency, impact severity, and mitigation complexity
Reentrancy — the canonical attack in detail: An attacker deploys a contract with a 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.
Reentrancy Attack
Fig 6.12 — Reentrancy attack call graph: the cross-contract recursive drain pattern
VulnerabilityRoot CauseHistoric LossPrimary Mitigation
ReentrancyExternal call before state update$60M+ (DAO)CEI pattern, ReentrancyGuard
Integer OverflowSilent wrap-around in <0.8.x$8M (BEC token)Solidity 0.8+ built-in checks
Access ControlMissing onlyOwner / role checks$320M (Wormhole)AccessControl, multi-sig
Oracle ManipulationOn-chain price spot-reading$182M (Beanstalk)TWAP oracles, Chainlink
Flash Loan AttackUnchecked atomicity assumption$130M (Cream)Cooldowns, TWAP, multi-block
Tx.origin AuthUsing tx.origin instead of msg.senderMultipleAlways use msg.sender
Delegatecall InjectionArbitrary 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.

The audit severity hierarchy: Professional security auditors classify findings by CVSS-inspired severity. Critical (fund drain, unauthorized ownership transfer) must be fixed before deployment. High (privilege escalation, DoS) requires fix. Medium (incorrect behavior under edge cases) should be fixed. Low (best-practice deviations, informational) recommended improvements. A clean audit does not mean bug-free — it means no known issues found by the auditing team in the time allocated.
Audit Severity Distribution
Fig 6.13 — Audit finding severity distribution across major protocol audits 2021–2024

Gas 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.

Gas Optimization
Fig 6.14 — Gas cost breakdown by operation type and optimization impact

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.

Bytecode Size and Costs
Fig 6.15 — Contract bytecode size limits and deployment cost implications
Storage slot packing in practice: The EVM stores each state variable in a 32-byte (256-bit) slot. If consecutive variables fit within a single slot, the compiler packs them. Order matters: 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.

Solidity 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.

Foundry vs Hardhat
Fig 6.16 — Foundry vs. Hardhat feature comparison: compilation speed, test capabilities, and ecosystem support
Hardhat (JavaScript/TypeScript):
• 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
Foundry (Rust-based):
• 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.

Critical invariants to fuzz for a simple token contract:
• 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 Patterns
Fig 6.17 — Error handling mechanisms: require/revert vs. custom errors, gas comparison, and debuggability

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).

The 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.

Deployment Flow
Fig 6.18 — Production deployment pipeline: from local development to mainnet verification

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.

Upgradeable contracts — the trust trade-off: The transparent proxy pattern (OpenZeppelin’s UUPS or TransparentUpgradeableProxy) allows the logic contract to be replaced while the proxy holds persistent storage. This enables bug fixes post-deployment. The cost: the upgrade admin key is a single point of failure. If the admin key is compromised, an attacker can upgrade to a malicious implementation and drain all funds. Mitigations: Gnosis Safe multi-sig for the admin, a timelock contract (48–72 hour delay before upgrades take effect), and governance voting for major protocol changes.
Proxy Upgrade Pattern
Fig 6.19 — Proxy upgrade pattern: storage layout, delegatecall mechanics, and admin key governance

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.

Selected slides from the L06 full deck (34 slides)
Slide 1
01 — Title
Slide 4
04 — Contract Structure
Slide 8
08 — Function Visibility
Slide 13
13 — CEI Pattern
Slide 17
17 — Gas Optimization
Slide 22
22 — Security Vulns
Slide 27
27 — Deployment Flow
Slide 34
34 — Key Takeaways

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.

ERC-20 Token Flow
Fig 6.20 — ERC-20 token standard: transfer, approve, and transferFrom flow with allowance mechanics
© 2025 BSc Blockchain Course · Solidity Programming · Course Home