Q1: How does the interpreter loop (Run) work?
Run() is the heart of the EVM — an infinite for loop that executes bytecode instructions one at a time.
Before entering Run
evm.Call() sets up the execution context before handing off to the interpreter:
evm.Call(sender, to, input, gas, value) 1. depth > 1024? → prevent infinite recursion 2. sufficient balance? → check caller can afford value transfer 3. snapshot state → for rollback on error 4. is precompile? → call Go function directly, skip interpreter 5. load target bytecode → resolveCode(addr) 6. create Contract object → packages caller, address, gas, code 7. enter Run()On error, RevertToSnapshot rolls back state. If the error is REVERT, remaining gas is returned; for other errors (e.g., OOG), all gas is consumed.
Inside Run
Upon entering Run(), per-call-frame resources are allocated:
evm.depth++ // call depth +1stack = newstack() // from sync.Poolmem = NewMemory() // fresh memory bufferscope = &ScopeContext{Memory: mem, Stack: stack, Contract: contract}pc = 0 // program counter starts at 0Then the main loop runs, with 7 steps per iteration:
for { ① op = contract.GetOp(pc) // fetch bytecode[pc] as OpCode ② operation = jumpTable[op] // lookup: get execute func, gas, stack bounds ③ validate stack: enough elements? would overflow? ④ deduct constantGas (fixed cost, e.g., ADD=3) ⑤ if dynamicGas: compute + deduct (e.g., SLOAD cold/warm cost) ⑥ if memorySize: expand memory + charge memory gas ⑦ operation.execute(&pc, evm, scope) // execute! pc++}Walking through an ADD instruction:
① pc=5, bytecode[5] = 0x01 (ADD)② jumpTable[0x01] = {execute: opAdd, constantGas: 3, minStack: 2, maxStack: 1025}③ stack has 4 elements ≥ 2 ✓, 4-2+1=3 ≤ 1024 ✓④ contract.Gas -= 3⑤ ADD has no dynamicGas — skip⑥ ADD doesn't touch memory — skip⑦ opAdd: pop two numbers, add them, leave result on stack top pc++ → pc=6How the loop exits
- STOP / RETURN — return sentinel
errStopToken,Run()clears it tonil, normal exit - REVERT — return error + return data, caller rolls back state but refunds remaining gas
- ErrOutOfGas — not enough gas to deduct, loop breaks
- ErrStackUnderflow/Overflow — stack validation fails
- JUMP to invalid position —
ErrInvalidJump
On frame exit, the stack is returned to the pool, memory is freed, and evm.depth--.
Special handling for JUMP
Most opcodes just let pc++ advance to the next instruction. But JUMP and JUMPI modify *pc directly inside their execute function:
func opJump(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { pos := scope.Stack.pop() if !scope.Contract.validJumpdest(&pos) { return nil, ErrInvalidJump // can't jump into PUSH data } *pc = pos.Uint64() - 1 // -1 because the loop does pc++ after return nil, nil}validJumpdest uses a bitmap to mark which byte positions are real opcodes (vs. embedded data inside PUSH instructions), preventing jumps into the middle of data.
Q2: How do Call, StaticCall, and DelegateCall differ?
All three are ways for one contract to invoke another, but they differ in context and permissions.
Call — the standard invocation
Contract A calls Contract B├─ Executes B's code├─ Runs in B's context (msg.sender = A, address = B)├─ Can transfer ETH└─ Can modify B's storageA straightforward “I call you.” B sees msg.sender as A, address(this) as B.
StaticCall — read-only invocation
Contract A static-calls Contract B├─ Executes B's code├─ readOnly = true├─ Cannot transfer ETH (no value parameter)├─ Cannot modify any state (SSTORE, LOG, CREATE, SELFDESTRUCT all forbidden)└─ Violation → ErrWriteProtectionUsed for view / pure function calls. Guarantees the call produces no side effects.
DelegateCall — “borrow the code”
Contract A delegate-calls Contract B├─ Executes B's code├─ But runs in A's context!│ ├─ msg.sender = A's caller (not A)│ ├─ address(this) = A (not B)│ └─ storage reads/writes target A's storage (not B's)├─ Cannot transfer ETH└─ B's code operates on A's stateThis is the foundation of the proxy pattern. Proxy contract A holds no business logic — it delegate-calls logic contract B, but all state changes happen on A.
The key difference in code:
// Call: caller=A, address=Bcontract := NewContract(A, B, value, gas, ...)
// DelegateCall: caller=A's caller, address=Acontract := NewContract(originCaller, A, value, gas, ...)Comparison table
| Call | StaticCall | DelegateCall | |
|---|---|---|---|
| Whose code executes | B | B | B |
| msg.sender | A | A | A’s caller |
| address(this) | B | B | A |
| Storage operations | B’s | B’s (read-only) | A’s |
| Can transfer ETH | Yes | No | No |
| Can modify state | Yes | No | Yes (modifies A) |
Create / Create2
Contract creation takes a different path but follows a similar structure:
- Compute new address —
CREATE:keccak256(rlp([sender, nonce])).CREATE2:keccak256(0xff ++ sender ++ salt ++ keccak256(initCode)). - Collision check — target address must not have non-zero nonce, non-empty code, or non-empty storage.
- Create account, transfer value, set nonce to 1.
- Execute init code (constructor). The returned bytes become the deployed code.
- Verify code size ≤ 24,576 bytes.
- Charge code storage gas:
len(code) × 200.
Q3: How does SSTORE gas work and why is it so complex?
SSTORE has the most complex gas rules in the EVM. The complexity stems from a core principle: the cost of writing storage should reflect the actual change you cause, not how many times you call SSTORE.
Three values
Gas calculation is based on comparing three values:
original — the slot's value at the start of the transaction (read from disk)current — the present value (may have been modified earlier in this transaction)new — the value you're writing nowScenario breakdown
Scenario 1: current == new (writing the same value)
Slot is currently 42, you write 42→ 100 gas (warm read price)Nothing changes, minimal charge.
Scenario 2: original == current, original == 0 (zero → non-zero)
Slot has never been used, you write 42→ 20,000 gasCreates a brand-new node in the trie. Most expensive.
Scenario 3: original == current, original != 0 (non-zero → different non-zero)
Slot is 42, you change it to 100→ 2,900 gasNode already exists, just updating the value.
Scenario 4: original == current, new == 0 (non-zero → zero)
Slot is 42, you clear it to 0→ 2,900 gas + refundDeletes a trie node, rewarded with a refund.
Scenario 5: original != current (dirty slot — already modified in this transaction)
Transaction start: slot = 42 (original)First write: slot = 100 (current, cost 2,900 gas)Second write: slot = 200 (new)→ Second write costs only 100 gas (warm read)You already paid the big price for this slot’s change on the first write. Subsequent modifications are charged the minimum.
Warm / Cold stacking
All scenarios above also stack with EIP-2929 warm/cold rules:
First access to this slot (cold) → additional +2,100 gasSubsequent access (warm) → no extra chargeSo a completely cold slot written from zero to non-zero: 2,100 (cold) + 20,000 (set) = 22,100 gas.
Why this design
The core goal is net gas metering. If a transaction writes the same slot 5 times, you only pay for “the difference between the final result and the original value,” not for 5 separate writes. This encourages contract developers to freely use temporary variables (e.g., updating a slot in a loop) without worrying about gas explosion.
The refund mechanism follows the same philosophy: clearing a slot frees a trie node, so you get a refund. But if you clear a slot and then write it back to non-zero in the same transaction, the refund is canceled — because the net effect is no node was freed.
Some information may be outdated






