A Solidity contract is not merely a collection of functions. It is a persistent program whose code executes in response to transactions and whose data is distributed across several distinct locations. Understanding where data resides, how it is encoded, and when it can be modified is central to writing secure and gas-efficient smart contracts.
This chapter examines storage, memory, and calldata; mappings, arrays, structs, and enums; constants and immutables; ABI encoding; events and custom errors; and the receive and fallback functions that govern contract interaction. These features are closely related: a function may receive ABI-encoded calldata, decode it into local variables, read a mapping in storage, construct a memory structure, emit an event, and either return data or revert with an error.
Because blockchain state is shared and persistent, poor data-location choices can increase transaction costs, create unintended state changes, or expose security vulnerabilities. Professional Solidity development therefore requires both syntactic knowledge and an execution model.
Learning Objectives
By the end of this chapter, you should be able to:
distinguish storage, memory, and calldata;
select appropriate data locations for values, arrays, structs, and function parameters;
explain the behaviour and limitations of mappings, arrays, structs, and enums;
use constants and immutables appropriately;
describe ABI encoding and function dispatch;
design events and custom errors for observability and efficient failure handling;
implement receive and fallback functions safely;
analyse gas, security, and governance implications of contract execution.
1. The EVM State Model
The Ethereum Virtual Machine (EVM) provides each contract with persistent storage represented conceptually as key–value slots. A storage slot is 32 bytes wide. Solidity usually hides the slot calculation, but understanding it is useful when analysing upgrades, storage collisions, and gas usage.
Contract code is stored separately from contract state. A transaction causes the EVM to execute bytecode using temporary memory and transaction-provided calldata. When execution completes successfully, permitted storage changes become part of the blockchain state. If execution reverts, state changes made during that call are rolled back, although transaction fees already consumed are not generally returned.
The EVM also maintains a transient execution stack. Solidity variables of small value types are commonly handled through stack operations, while larger or dynamically sized data requires memory or storage. The stack has a strict depth limit, which is one reason excessively complex expressions can cause compilation or execution problems.
A useful conceptual flow is:
Flow of data during Solidity contract execution
Figure: Flow of data during Solidity contract execution
Figure: Conceptual flow from transaction calldata to temporary data, persistent state, and event logs.
2. Data Locations
2.1 Storage
storage is persistent contract state. A state variable declared at contract level is stored there unless it is a constant or immutable. Reading storage costs gas, and writing storage generally costs considerably more than manipulating temporary values. A storage reference points to the actual persistent data rather than creating a copy.
For example:
uint256 public totalSupply;
mapping(address => uint256) private balances;
An assignment such as balances[msg.sender] = 100 changes persistent state. A storage reference can be useful when modifying a complex structure:
User storage user = users[msg.sender];
user.active = true;
The reference does not duplicate user; it aliases the state object. Consequently, careless use of storage references can create unintended modifications.
2.2 Memory
memory is temporary and exists only during a message call. It is used for local arrays, dynamically sized values, and temporary copies of structures. Memory is erased after execution. A memory value passed to another internal function may be modified according to Solidity’s data-location rules, but it cannot itself persist after the call.
function names() external view returns (string[] memory result) {
result = new string[](2);
result[0] = "Ada";
result[1] = "Lin";
}
Memory expansion costs gas. The cost is not simply proportional to the number of bytes; the EVM uses a gradually increasing expansion cost. Nevertheless, avoiding unnecessary large memory copies is a sound design principle.
2.3 Calldata
calldata is the read-only byte array supplied with an external call. It is generally the cheapest location for external function parameters because the contract can read the transaction input without copying it into memory.
function register(string calldata name) external {
// name can be read but not modified in place
}
Calldata is immutable during execution. It is appropriate for external parameters, especially large arrays, strings, and byte arrays that do not need modification. A function cannot return a calldata variable as a persistent object.
2.4 Comparison
Location
Lifetime
Mutable?
Typical use
Main consideration
storage
persistent
yes
state variables
expensive reads/writes; aliasing
memory
one call
yes
temporary data
copying and expansion cost
calldata
one call
no
external parameters
efficient and read-only
For reference types such as arrays, strings, bytes, and structs, an explicit location is commonly required for local variables and parameters. Value types such as uint256, address, and bool are usually handled as values rather than references.
3. Compound Data Structures
3.1 Mappings
A mapping associates a key type with a value type:
mapping(address => uint256) public balances;
mapping(address => mapping(uint256 => bool)) public approvals;
Mappings do not store a list of keys. Every possible key conceptually exists and returns the value type’s default value when it has never been assigned. For example, an absent uint256 entry returns zero and an absent bool entry returns false.
This has important consequences. A contract cannot enumerate a mapping without maintaining a separate array or index. A common pattern is:
The array supports enumeration, while the mapping supports efficient membership checks. Developers must prevent duplicate insertion and must consider the gas cost of iterating over a growing array.
Mapping keys may be value types, strings are not directly valid mapping keys, and mappings cannot generally be returned from public interfaces. Nested mappings are useful, but their access and deletion logic must be carefully designed.
3.2 Arrays
Arrays may be fixed-size or dynamic:
uint256[10] public scores;
uint256[] public deposits;
Dynamic arrays support push() and pop(). Removing an element with delete resets it to its default value but does not shorten the array. Removing by shifting later elements preserves order but can be expensive. Removing by swapping with the last element is cheaper but changes order:
uint256 last = values[values.length - 1];
values[index] = last;
values.pop();
This pattern is appropriate only when order is not semantically significant and index has been validated.
Unbounded loops are a major operational risk. If a function loops over a storage array whose length grows without limit, it may eventually exceed the block gas limit and become unusable. Pagination, bounded batches, or off-chain indexing should be considered.
3.3 Structs
A struct groups related fields:
struct Position {
uint128 amount;
uint64 openedAt;
bool active;
}
mapping(address => Position) public positions;
Structs improve readability and make domain concepts explicit. Their fields may be packed into fewer storage slots when their sizes and ordering permit. Packing can reduce storage costs, but it may also cause a write to one field to involve reading and rewriting a shared slot. Optimisation should therefore be measured rather than assumed.
A storage struct can be modified in place; a memory struct is a temporary copy. Assigning between locations may copy the entire object, which can be expensive for nested or dynamic structures.
3.4 Enums
An enum represents a finite set of named states:
enum Status { Pending, Approved, Rejected }
Status public status;
The first member has the numeric value zero. Enums are useful for state machines, but transitions should be controlled explicitly. A contract should not assume that a user can safely provide arbitrary numeric values corresponding to enum members. Public interfaces encode enums using integer representations, and validation remains the developer’s responsibility.
4. Constants and Immutables
A constant variable is fixed at compile time. It cannot be assigned in a constructor and is embedded into the bytecode wherever used:
uint256 public constant MAX_FEE_BPS = 500;
An immutable variable is assigned once, normally in the constructor, and cannot be changed thereafter:
Constants are appropriate for universal protocol parameters. Immutables are appropriate for deployment-specific values such as an administrator, token address, or treasury. Neither is ordinary mutable storage, so they can be cheaper to read than regular state variables. They should not be used to create a false impression of governance flexibility: changing an immutable value requires deploying a new contract.
5. ABI Encoding and Function Dispatch
The Application Binary Interface (ABI) specifies how Solidity values are represented in external calls and return data. A standard function call begins with a four-byte selector followed by encoded arguments. The selector is the first four bytes of the Keccak-256 hash of the canonical function signature.
For example, the canonical signature for transfer(address,uint256) includes no spaces and uses the ABI type names. The selector is not a security credential; it merely identifies a function. A fallback function can receive unknown selectors, and selector collisions, although uncommon, must be considered in complex systems.
Static types occupy fixed 32-byte ABI words. Dynamic types such as bytes, string, and dynamic arrays use offsets and length fields. ABI encoding is therefore not equivalent to simply concatenating values. Standard ABI encoding is designed for self-describing offsets and deterministic decoding, whereas packed encoding may place values adjacent to one another and can be ambiguous.
abi.encode is generally preferred for cryptographic hashing when unambiguous boundaries matter:
abi.encodePacked can be useful for compact representations, but different tuples can produce the same packed byte sequence when multiple dynamic values are concatenated. If packed encoding is used for signatures or commitments, types and boundaries must be designed to prevent ambiguity.
The caller must check success, interpret return data correctly, and consider reentrancy. A low-level call does not automatically enforce the interface assumptions that a typed Solidity call provides.
6. Events, Errors, and Reverts
Events are transaction logs intended primarily for off-chain consumers. They are not contract storage and cannot be read by another contract through Solidity. An indexed event parameter is placed in a searchable topic; non-indexed data is stored in the event data area. At most three ordinary parameters can be indexed, in addition to the event signature topic.
Events support wallets, explorers, accounting systems, and monitoring infrastructure. They should describe meaningful state transitions, but they must not be treated as the authoritative state: logs can be unavailable to contract logic, and an event does not substitute for a state variable.
Custom errors provide structured and gas-efficient failure information:
Compared with long revert strings, custom errors generally reduce deployment and revert-data costs. Errors should communicate a condition useful to callers and auditors. They do not automatically protect funds or replace access control.
A revert rolls back state changes made in the current call frame and propagates according to the call mechanism. External systems should distinguish a deliberate revert from an unavailable target, malformed return data, or a failed low-level call.
7. Receive and Fallback Functions
A contract may receive Ether through a receive function:
receive is selected when calldata is empty. It must be external and payable. If no suitable receive function exists, a plain Ether transfer may revert, depending on the receiving contract’s other functions and fallback definition.
A fallback function is selected when no function selector matches, or when calldata is empty and no receive function is available. It may be declared payable:
fallback() external payable {
// Handle unknown calls or deliberately reject them
}
Fallbacks are used in proxy contracts, routers, and compatibility layers. They are also a frequent source of vulnerabilities because they may delegate execution, accept Ether unexpectedly, or obscure which code is actually running. A fallback should be minimal, deliberate, and thoroughly tested. If unknown calls should fail, explicitly revert rather than silently accepting them.
The 2,300-gas stipend historically associated with certain Ether transfer patterns should not be used as a security guarantee. Gas rules and recommended transfer patterns have evolved. Contracts should use explicit accounting, checks-effects-interactions, and reentrancy protections where appropriate.
8. Contract Execution and Security Principles
Checks-effects-interactions
A robust external-call sequence is:
check permissions and inputs;
update internal state;
interact with the external contract.
For a withdrawal, the balance should normally be reduced before sending Ether. This limits the damage from reentrant calls, although it is not the only required defence.
Access control
State-changing functions should specify who may call them. msg.sender identifies the immediate caller, while tx.origin identifies the original transaction originator and should not be used for authentication. A contract call through a trusted intermediary has the intermediary as msg.sender.
Gas and denial of service
Storage writes, large memory allocations, event data, and unbounded loops affect execution cost. A function that is theoretically correct may be operationally unusable if its gas demand grows with an attacker-controlled array. Gas limits are therefore part of the security model.
Upgradeability and storage layout
Proxy systems delegate execution while retaining state in the proxy. The implementation must preserve storage-slot ordering and types across upgrades. Adding variables in an unsafe position can overwrite existing state. Namespaced storage and established upgrade patterns reduce collision risk, but they do not eliminate governance and initialization risks.
Checks and effects of ABI assumptions
Contracts should validate array lengths, numeric ranges, addresses, signatures, nonces, and deadlines. ABI decoding may reject malformed data, but correctly encoded malicious data remains valid input. The contract must enforce business rules explicitly.
9. Illustrative Case: A Time-Limited Escrow
Background
Consider an illustrative escrow contract for a freelance platform. A buyer deposits Ether, the seller completes work, and an authorised arbitrator can resolve disputes. The contract must maintain balances and deadlines, emit useful events, and avoid unsafe external calls.
Problem
A naive design stores deposits in a mapping and, upon release, sends Ether before clearing the balance. A malicious recipient contract could re-enter the release function and withdraw repeatedly. A second problem is an unbounded list of all escrows, which may make administrative processing impossible as usage grows.
Analysis
The escrow record requires persistent state, so it belongs in storage. A struct provides a coherent representation:
The buyer and seller are stored in the struct; a mapping from identifier to struct provides direct access. A separate counter avoids attempting to enumerate the mapping. Events allow an off-chain indexer to build a searchable history.
Approach and solution
A release function should validate the escrow, record that it is released, and only then transfer funds. The external transfer should be checked. A production contract would also incorporate role management, reentrancy protection where appropriate, and a clear dispute state machine.
The example is illustrative rather than production-ready. It would need constructor and creation logic, validation that the identifier exists, dispute resolution, protection against zero addresses, and a policy for failed payments. It also demonstrates why a storage reference is useful: the release flag is updated in persistent state before the external interaction.
Outcome and learning points
The design separates persistent escrow state from transaction input and temporary values. It avoids mapping enumeration, produces events for indexing, uses custom errors for structured failure, and follows the checks-effects-interactions ordering. The case also shows that data-location choices are inseparable from security design.
10. Best Practices and Contemporary Developments
Use calldata for read-only external reference parameters whenever possible.
Use storage references only when intentional in-place modification is required.
Avoid unbounded storage loops and design pagination early.
Prefer custom errors for machine-readable failures.
Emit events for important state transitions, but retain authoritative state on-chain.
Use constants for compile-time values and immutables for deployment-time values.
Validate all low-level call results and avoid arbitrary delegate calls.
Treat fallback functions as security-sensitive entry points.
Document ABI assumptions and test malformed as well as normal inputs.
Use static analysis, fuzz testing, invariant testing, and independent review.
Modern Solidity tooling increasingly combines compiler diagnostics, symbolic analysis, fuzzing, and AI-assisted code review. Such tools can identify suspicious patterns, but they cannot establish that a protocol’s economic incentives or governance assumptions are sound. Human specification, threat modelling, and independent verification remain essential.
Challenges and Limitations
Data-location optimisation can conflict with readability. Storage packing may lower costs but make upgrades and partial writes more complex. Events improve observability but are not queryable by contracts. Custom errors provide useful information but require clients to understand the ABI. Fallbacks enable flexible architectures but make control flow harder to audit. Finally, gas costs vary with network conditions and protocol changes, so benchmarks should be performed against the intended compiler, EVM version, and deployment environment.
Chapter Summary
Solidity execution involves persistent storage, temporary memory, read-only calldata, and transient stack values. Storage references modify state directly; memory supports temporary mutable data; calldata efficiently supplies immutable external input. Mappings provide efficient key-based access but no built-in enumeration, while arrays, structs, and enums model collections, records, and finite states.
Constants are compile-time values and immutables are assigned once during deployment. ABI encoding defines how calls, parameters, return values, and errors cross the contract boundary. Events provide indexed transaction logs for off-chain systems, whereas custom errors provide structured and economical revert information. Receive and fallback functions control Ether reception and unmatched calls, but both require careful security analysis.
Professional design combines correct data locations with bounded computation, explicit validation, safe external-call ordering, access control, event-based observability, and upgrade-aware storage discipline.
Key Terms
Storage: Persistent key–value state associated with a contract.
Memory: Temporary, mutable data area for one call.
Calldata: Read-only transaction input supplied to an external function.
Stack: EVM execution area for short-lived operands and local values.
Mapping: Key–value association with default values for absent keys.
Array: Ordered collection that may be fixed-size or dynamic.
Struct: User-defined grouping of related fields.
Enum: Finite set of named values.
Constant: Compile-time value that cannot be changed.
Immutable: Deployment-time value that cannot be changed after construction.
ABI: Application Binary Interface describing external data encoding.
Function selector: Four-byte identifier derived from a canonical function signature.
Event: Transaction log intended for off-chain consumers.
Indexed parameter: Event parameter stored in a searchable topic.
Custom error: Named, typed revert condition.
Receive function: Payable entry point selected for empty calldata.
Fallback function: Entry point for unmatched calls or, in some cases, empty calldata.
Reentrancy: Repeated entry into a contract before an earlier operation finishes.
Storage collision: Unintended overlap between variables occupying the same storage location.
Review Questions
5 Short Answer Questions
What is the principal difference between storage and memory?
Why can a mapping not normally be enumerated directly?
When is calldata preferable to memory for an external parameter?
What purpose does an indexed event parameter serve?
Under what condition is a receive function selected?
5 Long Answer Questions
Explain storage, memory, and calldata in terms of lifetime, mutability, cost, and common use.
Compare mappings, arrays, structs, and enums, including one suitable application for each.
Explain ABI encoding, function selectors, dynamic values, and the risks of ambiguous packed encoding.
Discuss how events and custom errors support observability, client integration, and efficient failure handling.
Analyse the security risks of receive and fallback functions and propose design controls.
3 Analytical / Application Questions
Design a data model for a token vesting system using mappings, arrays, structs, and enums. Justify each choice and identify any enumeration or gas risks.
A withdrawal function sends Ether to a user before reducing the user’s balance. Explain the attack sequence and redesign the function using appropriate execution ordering.
An upgradeable proxy begins returning incorrect balances after a new variable is added to the implementation. Analyse the likely storage-layout problem and recommend preventive practices.
Multiple Choice Questions with Answers
Question 1
Which data location is read-only and commonly used for external function parameters?
A. storage
B. memory
C. calldata
D. stack
Correct Answer: C. calldata
Question 2
What happens when a mapping key has never been assigned?
A. The call always reverts
B. The value type's default value is returned
C. A new key is automatically added to an iterable list
D. The mapping returns null
Correct Answer: B. The value type's default value is returned
Question 3
Which statement about an immutable variable is correct?
A. It can be changed by any caller after deployment
B. It must be assigned at compile time
C. It is normally assigned once during construction and cannot later change
D. It is always stored in a dynamic array
Correct Answer: C. It is normally assigned once during construction and cannot later change
Question 4
What is the principal purpose of an event?
A. To create persistent storage readable by Solidity contracts
B. To provide transaction logs for off-chain consumers
C. To bypass access control
D. To replace all state variables
Correct Answer: B. To provide transaction logs for off-chain consumers
Question 5
What does a custom error primarily provide?
A. A structured and efficient way to report a revert condition
B. Automatic protection against reentrancy
C. A searchable event topic
D. A replacement for ABI encoding
Correct Answer: A. A structured and efficient way to report a revert condition
Question 6
A receive function is selected when a contract receives Ether with:
A. A matching non-empty function selector
B. Empty calldata, provided the receive function is payable
C. Only a failed delegatecall
D. A storage collision
Correct Answer: B. Empty calldata, provided the receive function is payable
Question 7
Which operation changes persistent contract state?
A. Assigning a value to a local memory variable
B. Reading calldata
C. Updating a storage variable
D. Decoding a function selector
Correct Answer: C. Updating a storage variable
Question 8
Why can an unbounded loop over a storage array be dangerous?
A. It automatically deletes the array
B. It may eventually exceed the block gas limit
C. It converts the array into a mapping
D. It makes calldata mutable
Correct Answer: B. It may eventually exceed the block gas limit
Question 9
Which sequence best reflects checks-effects-interactions?
A. External call, state update, input check
B. State update, external call, access check
C. Input and access checks, state update, external call
D. Event emission, external call, state update
Correct Answer: C. Input and access checks, state update, external call
Question 10
What is a major risk of using abi.encodePacked with multiple dynamic values?
A. It always uses storage instead of memory
B. It can create ambiguous concatenations
C. It prevents all hashing operations
D. It automatically emits an event
Correct Answer: B. It can create ambiguous concatenations