Contract Execution and State ManagementIntroductionEthereum smart contracts are programs whose persistent state is stored on a replicated blockchain and whose execution is initiated by transactions or by other contracts. Understanding Solidity syntax alone is insufficient for safe development. A professional developer must also understand where data is stored, how the Ethereum Virtual Machine (EVM) executes calls, how function selectors route calldata, how Ether enters and leaves contracts, and how failures affect the entire transaction. This chapter examines these execution mechanisms as an integrated system. The central idea is that a contract operation has both data context and execution context. Data context concerns storage, memory, calldata, and return data. Execution context concerns the caller, transaction origin, contract address, Ether value, gas, and code being executed. Errors and reverts connect the two: unless handled by an external boundary, a failed call restores the state changes made in that call frame and propagates failure to its caller. Learning ObjectivesBy the end of this chapter, a learner should be able to: - distinguish storage, memory, calldata, and stack data;
- reason about Solidity storage slots and dynamic data layout;
- explain ABI encoding and four-byte function selectors;
- design and audit
fallback and receive functions; - compare Ether transfer mechanisms and their security implications;
- use events, custom errors, modifiers, and explicit error handling appropriately;
- distinguish
call, delegatecall, and ordinary internal calls; - explain
msg.sender, msg.value, address(this), and tx.origin across call boundaries; - apply the principle of atomic execution to contract design and debugging.
1. The EVM Execution ModelA transaction sent to a contract supplies a destination address, calldata, an Ether value, and a gas limit. The EVM creates an execution frame. Within that frame, the contract may read and modify state, call other contracts, emit logs, return data, or revert. The most important state components are: | Component | Persistence | Typical purpose | Main characteristic |
|---|
| Storage | Persistent between transactions | Contract state variables | Expensive, key-value structure of 32-byte slots | | Memory | Exists only during one call | Temporary arrays and intermediate data | Expandable and discarded after execution | | Calldata | Read-only input for an external call | Function arguments | Cheap to read and cannot be modified | | Stack | During EVM execution | Operands and local values | Limited to 1024 items, each 256 bits | | Logs | Persisted as transaction receipts | Off-chain event notifications | Not readable by contracts as storage |
A contract does not “run continuously.” It executes in response to an external transaction or an internal message call. Its storage changes become part of the canonical chain only if execution completes successfully. A revert discards state changes made in the reverted call frame, although transaction-level effects such as gas consumption and the existence of the transaction receipt remain observable. 1.1 Calls and execution framesA call creates a new execution context. Important context variables include: msg.sender: the immediate caller of the current call frame;msg.value: Ether transferred with the current call;address(this): the address whose context is currently executing;tx.origin: the externally owned account that originated the transaction;msg.data: the complete calldata for the current call;gasleft(): the gas remaining in the current frame.
tx.origin normally remains the same through a chain of calls, whereas msg.sender changes at each external call boundary. Authentication should generally use msg.sender, not tx.origin, because a malicious intermediary contract can exploit origin-based authorization.
2. Solidity Data Locations and Storage LayoutSolidity requires reference types such as arrays, mappings, structs, and strings to have an explicit data location in many contexts. Choosing the location is both a correctness and gas-efficiency decision. 2.1 StorageStorage is a persistent key-value map indexed by 256-bit slots. A slot contains 32 bytes, or 256 bits. State variables are assigned slots according to Solidity’s layout rules. Value types can be packed into one slot when their combined size permits and when their ordering makes packing possible. For example: contract Packed {
uint128 public a; // may occupy the lower 16 bytes of slot 0
uint128 public b; // may occupy the upper 16 bytes of slot 0
uint256 public c; // occupies the next available slot
}The exact layout must be confirmed from the compiler’s storage-layout output when interacting with upgradeable contracts or manually reading storage. Packing can reduce storage use, but it may require a read-modify-write operation when only part of a slot changes. Thus, packing is not automatically cheaper in every access pattern. Mappings and dynamic arrays use derived slots rather than simply consuming consecutive slots. If a mapping is declared at slot p, the value associated with key k is generally located at the slot computed from the cryptographic hash of the encoded key and p: slot = keccak256(abi.encode(k, p)) Here, k is the mapping key, p is the mapping’s assigned base slot, and slot identifies the storage location of the value when the value is a simple type. For a mapping whose value is a struct or another mapping, additional offsets or hashes are applied. The formula is meaningful only under the Solidity storage-layout rules and with the correct ABI encoding of the key and slot. For a dynamic array at slot p, the slot p stores the array length. The elements begin at a location derived from keccak256(p). If each element occupies one slot, element i is found at: keccak256(p) + i This is a conceptual layout rule; actual access should normally be performed through Solidity rather than manually reconstructed. 2.2 MemoryMemory is a temporary byte-addressed area. It is available during a call and is discarded when that call finishes. Solidity uses memory for dynamically sized values created during execution, ABI encoding, and return data. Memory expansion has a gas cost, so repeatedly allocating large temporary structures can be expensive. Memory arrays cannot normally be resized after creation. A developer should allocate an appropriate length and fill its elements. Memory is not automatically persistent: assigning a memory reference to another memory reference may cause both variables to refer to the same temporary object, whereas copying between storage and memory creates a different kind of relationship. 2.3 CalldataCalldata is the read-only byte sequence supplied to an external function call. Parameters declared with calldata are particularly useful for external functions because the contract can read the input without copying it into memory. function submit(bytes calldata data) external {
// data can be inspected but not modified in place
}Calldata is controlled by the caller and must never be assumed to be well formed merely because a Solidity ABI decoder is used. The decoder checks many structural conditions, but applications must still validate ranges, authorization, signatures, deadlines, and business rules. 2.4 Storage pointers and copyingA local variable referring to a storage object may act as a storage pointer: uint256[] storage values = records[user];
values.push(amount); Changes through values modify persistent state. By contrast, a memory copy can be modified without changing storage until an explicit assignment is made. Confusing these relationships is a common source of vulnerabilities and unintended state changes. 3. ABI Encoding and Function SelectorsThe Application Binary Interface (ABI) defines how function calls and return values are encoded. Standard calldata begins with a four-byte function selector followed by ABI-encoded arguments. The selector is the first four bytes of the Keccak-256 hash of the canonical function signature: selector = bytes4(keccak256("functionName(type1,type2,...,typeN)")) The canonical signature uses types such as uint256, not source-level aliases such as uint; it contains no spaces and excludes parameter names and return types. For example, the selector for transfer(address,uint256) is obtained by hashing that exact canonical string and taking the first four bytes. A selector is not a complete security identity. Different signatures can theoretically produce the same four-byte selector, known as a selector collision. Contracts that dispatch manually must therefore be carefully designed, and proxy systems must ensure that delegated implementations do not expose conflicting or dangerous functions. A contract’s dispatcher usually examines msg.data[0:4], compares it with known selectors, decodes arguments, and jumps to the relevant function. If no selector matches, execution may proceed to fallback, if defined. If calldata is empty and a payable receive function exists, the receive function may be selected instead. 4. Receive and Fallback FunctionsSolidity provides two special entry points: receive() external payable { }
fallback() external payable { }The receive function is called when the contract receives Ether with empty calldata. It must be external and payable. A fallback function is called when no function selector matches, or when calldata is non-empty and no matching function exists. It may be non-payable or payable. A contract can have both functions. Their typical selection logic is: | Calldata | Ether | Available function | Result |
|---|
| Empty | Any | Payable receive | receive executes | | Empty | Any | No usable receive | Payable fallback may execute | | Non-empty, unknown selector | Any | Payable fallback | fallback executes | | Non-empty, unknown selector | Ether sent | Non-payable fallback | Reverts |
A fallback function is often used in proxies, but it is dangerous as an unrestricted general-purpose entry point. It should be small, explicit about its behavior, and protected against unauthorized administrative calls. A receive function should not silently perform complex operations merely because Ether arrived unexpectedly. 5. Ether Transfers and ReentrancyContracts can transfer Ether using Solidity’s address methods or low-level calls. The main approaches are: payable(recipient).transfer(amount);
payable(recipient).send(amount);
(bool ok, bytes memory data) = payable(recipient).call{value: amount}("");transfer reverts on failure and historically forwards a small fixed gas stipend. send returns false on failure rather than automatically reverting. Both are constrained by assumptions about the recipient’s fallback gas requirements, and those assumptions became less reliable as gas costs evolved.
A low-level call is generally the most flexible approach. It forwards gas according to the call’s gas rules and returns a success flag and return data. It must be checked explicitly: (bool success, ) = payable(recipient).call{value: amount}("");
if (!success) revert EtherTransferFailed();A successful call does not prove that the recipient behaved according to business expectations. It only indicates that the called frame did not revert. A recipient can execute arbitrary code, including a reentrant call back into the sending contract. 5.1 Checks-effects-interactionsA standard defense is the checks-effects-interactions pattern: - Checks: validate authorization and conditions.
- Effects: update internal state.
- Interactions: call external contracts or transfer Ether.
For example, a withdrawal function should reduce the user’s recorded balance before making the external call. A reentrancy guard may provide an additional defense, but it should not replace sound state ordering and access control. 6. Events and Off-Chain ObservabilityEvents create logs stored in transaction receipts. They are intended for wallets, indexers, analytics systems, and user interfaces. Contracts cannot query historical event logs as if they were storage. event Withdrawal(
address indexed account,
uint256 amount
);
emit Withdrawal(msg.sender, amount);An event may contain up to three indexed parameters in ordinary Solidity usage. Indexed values are placed in log topics and are efficient for filtering. Non-indexed values are ABI-encoded in the data section. Indexing a dynamic type stores a hash rather than the complete value, so the original dynamic content may not be recoverable from the topic alone. Events are not a substitute for state. If a balance must be enforced by the contract, it belongs in storage. Events should describe meaningful state transitions and should be emitted after the relevant checks and state updates have logically succeeded. If the transaction reverts, logs emitted in the reverted frame are also removed. 7. Errors, Reverts, and Custom ErrorsA failed operation can revert. Reversion undoes state changes in the reverted call frame and returns failure data to the caller. Solidity offers several error mechanisms: require(condition, "message") for common precondition checks;revert("message") for explicit string-based failure;- custom errors for typed and gas-efficient failure data;
assert(condition) for internal invariants that should never fail during correct execution.
Custom errors are declared and used as follows: error NotAuthorized(address caller);
error InsufficientBalance(uint256 available, uint256 requested);
if (msg.sender != owner) revert NotAuthorized(msg.sender);
if (balance < amount) {
revert InsufficientBalance(balance, amount);
}Custom errors communicate structured information while usually producing less deployment and runtime overhead than long revert strings. They are part of the contract’s externally observable interface and should be documented. A caller can catch failures from an external call using try/catch: try target.perform(value) returns (uint256 result) {
// success path
} catch Error(string memory reason) {
// Error(string) revert
} catch (bytes memory lowLevelData) {
// custom errors and other low-level failure data
}Catching an error does not automatically restore the failed operation’s effects in the caller. It only allows the caller to decide what to do next. If the caller itself completes successfully, its own state changes may remain committed. 8. Modifiers and Control-Flow DisciplineModifiers express reusable preconditions or execution wrappers: modifier onlyOwner() {
if (msg.sender != owner) revert NotAuthorized(msg.sender);
_;
}
function pause() external onlyOwner {
paused = true;
}The placeholder _ marks where the function body executes. Modifiers can perform checks before and after the body, but complex modifiers may obscure control flow. A professional codebase should use modifiers for concise, easily understood conditions and ordinary functions for substantial logic. A modifier does not provide authorization by itself; its correctness depends on the state and condition it checks. Modifier order can matter, especially when one modifier changes state or performs an external interaction. Developers should inspect the expanded control flow during review. 9. Call, Delegatecall, and Context9.1 Ordinary external callWith target.call(...), the target contract’s code executes in the target’s context. The target’s storage, balance, and address are used. Within the target, msg.sender is the calling contract and msg.value is the value supplied by that call. 9.2 Delegatecalldelegatecall executes the target’s code while preserving the caller’s context. In the delegated code:
address(this) is the caller’s address;- storage is the caller’s storage;
- the caller’s balance is used;
msg.sender and msg.value are preserved from the original call context.
This mechanism enables proxy contracts, but it creates severe storage-layout obligations. The implementation code interprets storage slots belonging to the proxy. If the layouts differ, an assignment intended for one variable can overwrite another, including ownership or upgrade-control data. A proxy must also protect its administrative functions and avoid selector collisions between proxy and implementation interfaces. Upgrade procedures should validate implementation addresses, initialization state, and storage compatibility. 9.3 Internal callsAn internal Solidity function call does not create an external message-call boundary. It generally executes within the same contract context, so msg.sender and msg.value remain those of the current frame. Internal calls are therefore different from this.functionName(...), which creates an external call to the contract itself and changes the immediate caller to address(this). 10. Atomic Execution and Transaction BoundariesEthereum transactions are atomic at the level of the execution path that ultimately reverts or succeeds. If a function updates a balance and then makes a call that reverts, the balance update is normally undone when the revert propagates. If the contract catches the failure and returns successfully, the caller’s earlier state changes may remain. Consider this sequence: - Contract A checks a condition.
- A changes its storage.
- A calls Contract B.
- B reverts.
- A does not catch the failure.
The call to A reverts, and A’s state changes from step 2 are rolled back. If A catches B’s failure and continues, A can commit a new final state, including the effects made before the caught call. Atomicity is powerful but limited. It does not make external systems atomic with Ethereum. An off-chain service may observe an event before a later business process fails, and a bridge or oracle may involve separate transactions. Applications requiring cross-system consistency need explicit reconciliation and failure-handling procedures. 11. Worked Application: A Secure Escrow VaultThe following illustrative contract demonstrates storage, custom errors, events, modifiers, a withdrawal interaction, and checks-effects-interactions. It is not a complete production escrow system because it omits dispute resolution, deadlines, and administrative governance. contract EscrowVault {
mapping(address => uint256) public deposits;
address public immutable beneficiary;
bool private locked;
error NotBeneficiary(address caller);
error ZeroAmount();
error ReentrantCall();
error EtherTransferFailed();
event Deposited(address indexed from, uint256 amount);
event Released(address indexed to, uint256 amount);
modifier onlyBeneficiary() {
if (msg.sender != beneficiary) {
revert NotBeneficiary(msg.sender);
}
_;
}
modifier nonReentrant() {
if (locked) revert ReentrantCall();
locked = true;
_;
locked = false;
}
constructor(address _beneficiary) {
beneficiary = _beneficiary;
}
receive() external payable {
if (msg.value == 0) revert ZeroAmount();
deposits[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
function release(address payable recipient, uint256 amount)
external
onlyBeneficiary
nonReentrant
{
if (amount == 0) revert ZeroAmount();
(bool success, ) = recipient.call{value: amount}("");
if (!success) revert EtherTransferFailed();
emit Released(recipient, amount);
}
}This example illustrates several important points. The receive function accepts empty-calldata Ether transfers. The mapping is persistent storage. The beneficiary check uses msg.sender. The non-reentrancy modifier establishes a temporary lock. The external call is checked, and failure causes a revert. However, the example also exposes a design issue: a real escrow must define whose deposits are being released and must reduce an appropriate balance before transferring Ether. Merely adding a reentrancy guard does not correct an incorrect accounting model. This is a central professional lesson: security patterns are not substitutes for a complete state-transition specification. Case analysisBackground. A small organization wants a contract to hold Ether until an authorized beneficiary releases it. Problem. The organization initially writes a withdrawal function that transfers Ether before updating an accounting mapping. A recipient contract reenters during its fallback function and withdraws repeatedly. Analysis. The external call creates an opportunity for arbitrary recipient code to execute. Because the accounting state has not yet been reduced, each reentrant call observes the old balance. The vulnerability is caused by interaction preceding effects. Approach. The team specifies valid states, checks authorization, updates accounting before the external call, checks the call’s success flag, and adds a narrowly implemented reentrancy guard. It also emits events for deposits and releases and uses custom errors for predictable failures. Outcome. The reentrant path is blocked, failed transfers revert consistently, and off-chain systems can reconstruct successful deposit and release events. A formal audit is still required because the contract’s economic rules, upgrade policy, and emergency procedures may introduce additional risks. Learning points. External calls are trust boundaries; state invariants must be written before code; events aid observability but do not enforce rules; and modifiers must be reviewed as control-flow code. 12. Best Practices and LimitationsThe following practices are broadly applicable: - Use
calldata for read-only external inputs when appropriate. - Minimize storage writes and understand packing trade-offs.
- Never rely on
tx.origin for authorization. - Check the result of low-level calls.
- Follow checks-effects-interactions and consider reentrancy guards.
- Keep
receive and fallback functions minimal and intentional. - Use custom errors for structured, documented failures.
- Emit events for meaningful successful state transitions.
- Treat
delegatecall as an advanced mechanism requiring verified storage compatibility. - Test revert paths, malformed calldata, Ether transfers, and nested calls.
- Use compiler-generated storage-layout information for upgradeable systems.
- Treat compiler warnings, static-analysis findings, and audit reports as engineering inputs rather than optional documentation.
No single pattern eliminates all risk. Gas costs change through protocol upgrades, compiler behavior evolves, and composability means that a contract interacts with code it does not control. Security therefore requires specification, testing, code review, automated analysis, and operational governance. Chapter SummaryContract execution is organized around execution frames, persistent storage, temporary memory, read-only calldata, and ABI-defined inputs and outputs. Storage is a 32-byte-slot key-value system with special rules for packing, mappings, and dynamic arrays. Function selectors are the first four bytes of the Keccak-256 hash of a canonical function signature, and dispatch failures may invoke fallback or receive depending on calldata and Ether value. Ether transfers create external interaction and therefore potential reentrancy. Low-level calls require explicit success checking, while checks-effects-interactions and carefully designed guards reduce risk. Events provide off-chain observability but are not contract-readable state. Custom errors communicate structured failure information efficiently, and try/catch permits controlled handling of external failures. Ordinary calls change the execution context, whereas delegatecall executes another contract’s code against the caller’s storage and context. msg.sender is immediate-caller dependent; tx.origin is transaction-origin dependent and should not be used for authorization. Finally, unhandled reverts provide atomic rollback within the relevant execution path, but caught failures and off-chain processes require explicit consistency management. Key TermsStorage: Persistent contract data organized into 32-byte-addressable slots. Memory: Temporary, expandable data area discarded after a call. Calldata: Read-only input bytes supplied to an external call. Stack: EVM operand area containing 256-bit words. ABI: Specification for encoding contract calls, arguments, returns, and errors. Function selector: The first four bytes of the Keccak-256 hash of a canonical function signature. Fallback function: Special function invoked for unmatched selectors or, in some cases, Ether reception. Receive function: Payable special function invoked for empty-calldata Ether transfers. Event: Contract log intended primarily for off-chain consumers. Custom error: Typed, contract-defined revert information. Modifier: Reusable Solidity wrapper for checks or execution logic. Revert: An execution failure that rolls back state changes in the reverted frame. Reentrancy: Repeated entry into a contract before a previous operation has completed. Delegatecall: Execution of another contract’s code using the caller’s storage and context. Message call: A call between contracts that creates an execution boundary. msg.sender: Immediate caller of the current execution frame.
msg.value: Ether supplied with the current call.
tx.origin: Externally owned account that originated the transaction.
Atomic execution: All-or-nothing state commitment for an execution path that succeeds or reverts. Review QuestionsShort Answer Questions- What is the principal difference between storage and memory in Solidity?
- How is a function selector derived from a canonical function signature?
- Under what condition is a
receive function normally invoked? - Why should a contract check the Boolean result returned by a low-level
call? - What does
msg.sender represent, and how can it differ from tx.origin?
Long Answer Questions- Explain Solidity’s storage layout for packed value types, mappings, and dynamic arrays. Why should compiler storage-layout output be consulted for upgradeable contracts?
- Compare
call, delegatecall, and an internal function call with respect to storage, address context, and caller information. - Explain the checks-effects-interactions pattern and its relationship to reentrancy protection.
- Discuss the purposes and limitations of events, custom errors, and
try/catch. - Explain atomic execution using a nested call that reverts, and distinguish an uncaught revert from a caught failure.
Analytical / Application Questions- A contract updates a user’s balance only after sending Ether to the user’s address. Analyze the possible attack and redesign the sequence of operations.
- A proxy and its implementation assign different meanings to storage slot 0. Explain how a delegated call could corrupt ownership and propose controls for preventing the problem.
- Design the dispatch behavior for a contract that has a payable
receive, a payable fallback, and two ordinary functions. Specify what should happen for empty calldata, known selectors, unknown selectors, and non-payable calls carrying Ether.
Multiple Choice Questions with AnswersQuestion 1Which data location is persistent between transactions? A. Memory B. Calldata C. Storage D. Stack Correct Answer: C. Storage Question 2What is a Solidity function selector derived from? A. The full function body hash B. The first four bytes of the Keccak-256 hash of the canonical signature C. The contract deployment address D. The return type and parameter names Correct Answer: B. The first four bytes of the Keccak-256 hash of the canonical signature Question 3When is a payable receive function normally selected? A. When calldata is empty B. Whenever the selector is unknown C. Only during contract creation D. Only when the caller is the owner Correct Answer: A. When calldata is empty Question 4Which variable identifies the immediate caller of the current call frame? A. tx.origin B. address(this) C. msg.sender D. block.coinbase Correct Answer: C. msg.sender Question 5What is a major danger of using transfer before updating accounting state? A. It prevents event emission B. It can permit reentrancy against stale state C. It converts storage into memory D. It changes the function selector Correct Answer: B. It can permit reentrancy against stale state Question 6What does delegatecall preserve from the calling context? A. Only the target’s storage B. The caller’s storage context and execution address C. The target’s msg.sender only D. No context information Correct Answer: B. The caller’s storage context and execution address Question 7Which statement about events is correct? A. Contracts can directly query past events as storage B. Events are primarily logs for off-chain consumers C. Events cannot contain indexed parameters D. Events survive a reverted transaction Correct Answer: B. Events are primarily logs for off-chain consumers Question 8What is a principal advantage of custom errors? A. They automatically prevent reentrancy B. They provide structured failure data with lower overhead than many strings C. They make all calls internal D. They eliminate the need for access control Correct Answer: B. They provide structured failure data with lower overhead than many strings Question 9What normally happens when an external call reverts and the caller does not catch the failure? A. The caller’s preceding state changes are committed B. The entire relevant execution path reverts C. Only emitted events are removed D. The callee’s storage is copied to the caller Correct Answer: B. The entire relevant execution path reverts Question 10Which sequence best represents checks-effects-interactions? A. Interaction, check, effect B. Effect, interaction, check C. Check, effect, interaction D. Check, interaction, effect Correct Answer: C. Check, effect, interaction
|