Skip to content
Tech Interview Prep home
Technical interview guide

Smart Contract Development (Solidity & EVM)

Writing and deploying self-executing contract code on the Ethereum Virtual Machine — state, gas, and the constraints of on-chain execution.

Read
48 min
Practice MCQs
25
Interview QA
25
Edition
v3
Editorial status
Reviewed

Scope: Solidity latest documentation (0.8.36/0.8.37 development line reviewed 2026-09-04); Ethereum EVM documentation updated 2026-07-26; target-chain fork rules must be verified at deployment; ERC-1967, EIP-170, and EIP-1153.

Overview

Curated: · Written: · Reviewed:

Smart contracts are deterministic public state machines with irreversible consequences

A Solidity contract compiles to bytecode executed by the Ethereum Virtual Machine. Every validating execution client must derive the same post-state from the same pre-state and ordered transaction, so contract logic cannot directly depend on a developer's local clock, operating-system randomness, HTTP API, or hidden database. External facts arrive through transactions and oracle mechanisms with explicit trust. The chain provides replicated deterministic execution, not trustworthy inputs or automatically correct business rules.

This is the concept interviewers use to separate people who have written a toy contract from people who have shipped one. The probes come in a predictable order: how a call actually executes, what happens when an external call hands control to an attacker, what the operation costs, who is allowed to do it, and what happens when the code changes after deployment. A weak answer recites vocabulary — "gas is a fee," "reentrancy is bad" — without mechanism. A strong answer traces a call through the EVM, names the exact failure mode, and states the mitigation with its trade-off.

The execution model: how a call actually runs

The EVM is a 256-bit stack machine with four data locations that matter for every interview answer:

  • Storage — persistent contract storage, committed into global state, keyed by 32-byte slots. Expensive to write, survives forever.
  • Memory — transient, per-call-frame, wiped when the call returns.
  • Calldata — immutable input supplied by the caller; cheapest to read, cannot be written.
  • Stack — 1024 words deep, holds locals and intermediate values.

Solidity reference types must declare a data location because an unintended storage reference can mutate persistent state while an unintended memory copy can waste gas or lose changes. Since Solidity 0.8.24 (with EIP-1153 active on mainnet since the Cancun upgrade, March 2024) there is also transaction-scoped transient storage: shared across internal calls within one transaction, then cleared.

A call dispatches like this: the transaction's to field plus calldata reach the contract; the first four bytes of the calldata are the function selector — the first four bytes of the Keccak-256 hash of the canonical signature (e.g. transfer(address,uint256)). The contract's dispatcher compares it against its compiled function list and jumps. If nothing matches, the fallback function runs (or receive for a plain ether transfer with empty calldata). Selector collisions are possible, so proxy dispatch and interface assumptions require care. The ABI defines byte encoding but does not authenticate an address, prove source equivalence, or ensure the callee implements the expected semantics.

Caller context is the other half of the model. msg.sender is the immediate caller — an EOA or another contract. tx.origin is the EOA that signed the transaction. msg.value is the wei sent with the call, and only functions marked payable can receive it. State mutability beyond payable matters for both semantics and cost: view functions promise to read state but not write it, pure functions promise to touch neither state nor environment, and the compiler enforces both — a view function that attempts an SSTORE will not compile. Marking getters view also lets them be called locally without a transaction, which is why read-only helpers cost the caller nothing but gas-free local execution.

Visibility (public, external, internal, private) is access syntax between Solidity contracts, not confidentiality and not a security boundary on its own — a private variable is readable by anyone inspecting storage slots. public state variables get an auto-generated getter; external functions cannot be called internally except via this.f(), which routes through the dispatcher and costs a real external call.

Inheritance works by linearization, not by dynamic dispatch. Solidity copies inherited code into the derived contract following C3 linearization of the inheritance list, and state variables are laid out starting from the most base contract. When a derived contract redefines a base function, it must mark it override; the base must mark it virtual to allow it. Two consequences interviewers care about: there is no runtime method resolution — the most-derived override always wins, and calling the base implementation requires super.f(), which follows the linearization order, not the lexical parent. And because base contract state variables occupy the earliest storage slots, inheritance order is part of the storage layout — which is why reordering a contract's inheritance list in an upgrade corrupts state exactly like reordering variable declarations.

What interviewers probe: "Walk me through what happens when I call transfer on your token." They want selector → dispatcher → calldata decode → state read/write → events, in that order. Weak answer: "Solidity handles it." Follow-ups: what virtual/override actually do at the bytecode level, why super follows linearization order, what view/pure guarantee.

Reentrancy: the first security probe

External calls cross an adversarial boundary and can reenter before the caller finishes. The attack shape, which you should be able to draw on a whiteboard:

  1. Victim contract sends ether to attacker contract via call{value: ...}("").
  2. Attacker's receive() fires before the victim's balance update.
  3. Attacker calls withdraw again; the balance check still passes because the accounting hasn't been updated.
  4. Loop until the victim is drained.

The canonical fix is checks-effects-interactions: validate, update all state, then interact. Where ordering can't guarantee safety, use a narrowly scoped reentrancy guard (a mutex that reverts on reentry). Prefer pull-over-push payments: instead of looping transfers to creditors, let each claim its balance — this also bounds gas, since a push loop over a growing list can exceed the block gas limit and brick the function.

Know the variants, because interviewers escalate: read-only reentrancy (the attacker reads a stale view-like state during a callback — the Curve 2023 exploits — mitigated with reentrancy locks on read paths or one-block pricing), cross-function reentrancy (reenter a different function sharing the same state), and cross-contract reentrancy (reenter a different contract that trusts the victim's intermediate state). Reason across functions and contracts rather than guarding only one obvious withdrawal method.

A successful low-level call only says the callee did not revert; returned bytes must still be validated, and token contracts may have nonstandard behavior (missing return values, fees, blacklists).

What interviewers probe: "Write the vulnerable withdraw function, then fix it." Weak answer: "I'd add a ReentrancyGuard" with no mention of effects ordering — guards protect one function, not the state shared with others. Follow-ups: why transfer/send (2300 gas stipend) are no longer recommended for sending ether, what happens with a reentrancy guard and a non-reentrant external call in the same transaction.

Gas as a design constraint

Gas meters computation, storage, memory expansion and data use to bound denial of service. The sender chooses a transaction gas limit and fee parameters; execution that runs out of gas reverts state changes in that call context but still consumes paid work.

The numbers that matter (post-EIP-2929/EIP-3529, current mainnet):

  • SSTORE to a new nonzero value: 20,000 gas; to an existing nonzero value: 2,900 (plus a cold-access surcharge of 2,100 on first touch per transaction — warm vs cold access applies to storage, balances, and code).
  • SLOAD: 100 warm, 2,100 cold.
  • Memory expansion grows quadratically with the highest touched word — unbounded memory in a loop is a real cost.
  • Calldata reads are cheaper than memory copies; keeping large immutable arguments in calldata avoids a copy.
  • Refunds for clearing storage were reduced (EIP-3529) — don't design around refund harvesting.

Design consequences: pack compatible values into one slot (cautiously — brittle bit packing is its own bug class), avoid loops over user-growing storage (a function whose gas grows with the number of registrants becomes permanently unusable), and use unchecked blocks deliberately where overflow is provably impossible, not as a reflex. Solidity 0.8 performs checked integer arithmetic by default; unchecked restores wrapping behavior. Casting to a smaller integer can truncate, and division rounds toward zero — units, decimals, rounding direction and multiplication-before-division order are business invariants, particularly for assets.

Favor bounded work, pull-based claims, pagination and off-chain indexing with on-chain verification.

What interviewers probe: "This function iterates over all stakers to pay rewards — what's wrong with it?" Weak answer: "It's O(n), might be slow." Strong answer: names the block-gas-limit brick, the griefing vector (an attacker registers dust stakers to inflate n), and the pull-payment fix. Follow-ups: why a for loop over a mapping is impossible, what immutable vs constant cost (the value is inlined into runtime code — no SLOAD).

Access control and authorization

Authorization uses current state such as ownership, roles, signatures or governance, not transaction-origin shortcuts. Avoid tx.origin for authorization: if a victim can be induced to call an attacker-controlled intermediary, the intermediary preserves the victim as tx.origin while becoming msg.sender — the phishing pattern that makes tx.origin checks exploitable.

The standard patterns and when to pick each:

  • Ownable — a single owner with onlyOwner modifiers. Fine for prototypes; a single key is a single point of failure and compromise.
  • Role-based (OpenZeppelin AccessControl) — named roles identified by a hash of the role name, multiple holders per role, and granular grant/revoke through role admin roles. The default for real systems. (Time-delayed and scheduled permissioning — per-role delays, closings, relayers — lives in OpenZeppelin's separate AccessManager, which builds on top of role-based access; don't attribute those features to AccessControl itself.)
  • Signature-based (EIP-2612 permit, meta-transactions) — authorize with a signed message; requires nonce tracking and replay protection.

Privilege escalation usually enters through unprotected setters: an initializer left callable, an upgrade function without a role check, a delegatecall to an attacker-chosen address. delegatecall executes the callee's code in the caller's storage, balance and caller context — an unprotected delegatecall is total storage corruption. Privileged powers — upgrade, pause, mint, sweep, change oracle or set fees — must be minimal, time-delayed or multisignature-controlled where risk warrants, observable through events, and covered by compromise recovery.

What interviewers probe: "Why is tx.origin == msg.sender a bad check?" — they want the intermediary attack traced, not "it's deprecated." Follow-ups: how you'd rotate a compromised owner key, what a two-step ownership transfer (propose/accept) protects against.

Upgradeability and proxies

Deployment is normally irreversible, so upgradeability exchanges immutability for a governance and compatibility boundary. The mechanism: a proxy contract holds state and receives calls, and uses delegatecall to run an implementation contract's logic against the proxy's storage. The proxy's address and state persist; the logic pointer can change.

The main patterns:

  • Transparent proxy — the proxy itself dispatches: if the caller is the admin, calls go to proxy admin functions; otherwise everything forwards to the implementation. Costs extra gas on every call for the admin check.
  • UUPS — the upgrade logic lives in the implementation (upgradeToAndCall restricted by a role); the proxy is minimal and cheaper. If the implementation forgets the upgrade function, the proxy is stuck.
  • Beacon — many proxies consult one beacon for the implementation address, so one upgrade updates a fleet.

The failure modes are all storage-layout problems. delegatecall executes new logic against old proxy storage, so reordering, removing or changing existing state variables in an upgrade corrupts live state — the new code reads slot 3 as a uint128 when slot 3 holds an address. Mitigations: compare compiler storage-layout artifacts between versions, append only, use declared gaps (reserved unused slots) to leave room, and namespace extensions (e.g. ERC-7201 namespaced storage) rather than inserting. Inheritance order is part of this layout, as covered above — base-contract variables occupy the earliest slots.

Two more traps: constructors don't work with proxies — the implementation's constructor runs at implementation deployment, in the implementation's storage, never in the proxy's — so proxies use an initializer function, which must be callable exactly once (an uninitialized proxy can be taken over by whoever calls initialize first). And immutable/constant values are embedded in the implementation's runtime code, not read from proxy storage — they change on upgrade, which may or may not be what you want.

Standard slots such as ERC-1967 reduce collisions but do not validate the implementation. Storage-layout comparison, initializer discipline, upgrade authorization and rollback planning are release gates rather than optional conventions.

What interviewers probe: "Why can't you just add a state variable in the middle?" — they want the slot-by-slot misalignment, not "it breaks things." Follow-ups: transparent vs UUPS trade-offs, how a user-visible timelock delay mitigates a malicious upgrade, what happens to immutable values across an upgrade.

Reverts, events, and what actually happened

Reverts roll back state changes in the reverting call and its descendants unless a caller catches a failed external call and continues. Events are also discarded on revert. A transaction included in a block may have status failure while consuming gas, so applications must examine canonical receipts and expected events or state rather than equating transaction hash with success. Custom errors reduce revert-data cost and provide structured selectors, but error data from an external callee is untrusted.

Events are append-only execution logs for off-chain consumers, not contract storage — contracts cannot query historical logs. Index only fields clients genuinely filter, keep schemas versioned, and emit events for consequential administration and state transitions. Consumers must bind chain, block hash, transaction hash and log index, handle duplicates and reorganizations, and wait for policy finality before irreversible downstream effects.

What interviewers probe: "Your transaction was mined but the UI says it failed — what happened?" Weak answer: "A bug." Strong answer: separates broadcast, inclusion, receipt status and finality, and checks the receipt's status plus expected events.

Deployment, testing, and the production system

Deployment is a controlled change. Pin compiler version and settings, record source and dependency hashes, reproduce bytecode, verify constructor arguments and linked libraries, simulate on a fork, test the target chain and deployer nonce, verify runtime code, initialize atomically, transfer administration safely and monitor the first transactions. CREATE2 gives a predictable address from deployer, salt and initialization-code hash, but it does not authenticate future semantics or prevent dangerous code at that address.

Testing must combine examples with invariants and adversarial sequences. Unit tests cover branches; fuzzing explores input space; stateful invariant tests explore sequences and actors; fork tests exercise integration against real deployed code; differential tests compare models or implementations; static analysis finds known patterns; formal tools attempt to prove an explicit specification. None proves that the specification captures stakeholder intent, oracle behavior, governance, economics or every compositional interaction. Tests must cover zero, one, maximum values, dust, repeated rounding and adversarial conversion sequences.

Production engineering includes off-chain systems. Indexers can lag or fork, RPC providers can disagree, fee estimation can fail, transactions can be replaced, and user interfaces can show stale allowance or ownership. Persist intent and identifiers, make submission idempotent, reconcile from finalized state, operate multiple providers for critical paths, monitor reverts and privileged events, and rehearse pause, key rotation, upgrade and migration. The strongest contract is still part of a distributed socio-technical system.

What interviewers probe at staff level: not "did you test it" but "what did your testing not cover" — oracle behavior, governance capture, economic assumptions, compositional interactions with protocols you don't control.

The interview checklist

What interviewers actually ask, and what each weak answer sounds like:

  • "Is a private variable secret?" No — visibility is access syntax between contracts; anyone can read the storage slot. Weak answer: "yes, it's private."
  • "Why not use tx.origin for auth?" Intermediary phishing preserves the victim as origin. Weak answer: "it's discouraged."
  • "Your loop pays every user — what breaks?" Block gas limit bricks it as the list grows; griefers inflate the list. Weak answer: "performance."
  • "Fix this withdraw function." Checks-effects-interactions, then guard, then pull payments. Weak answer: a guard with no ordering reasoning.
  • "Add a variable to this upgradeable contract." Append only, compare layouts, gaps or namespaced storage. Weak answer: "just declare it."
  • "Why does the constructor not initialize the proxy?" It runs in the implementation's storage at implementation deployment. Weak answer: "proxies are weird."
  • "What does override do at runtime?" Nothing dynamic — the most-derived override is compiled in; super follows linearization. Weak answer: "it's like method overriding in Java."
  • "Transaction mined — did it succeed?" Check receipt status and events, not the hash. Weak answer: "yes, it's on Etherscan."

Likely follow-ups chain from these: read-only reentrancy after basic reentrancy, timelocks after access control, storage gaps after proxies, oracle trust after determinism. Prepare the next layer, not just the first answer.