Q1: What is the complete path of a JSON-RPC request from client to response?
Overall flow
Using eth_getBalance("0xAlice", "latest") as an example:
Client sends HTTP POST: {"jsonrpc":"2.0","method":"eth_getBalance","params":["0xAlice","latest"],"id":1} │ ▼Layer 1: Transport layer (HTTP / WebSocket / IPC) HTTP server receives request, reads JSON body │ ▼Layer 2: Server decoding codec.readBatch() → parse into jsonrpcMessage struct Create handler, call handleMsg() │ ▼Layer 3: Method dispatch handleCallMsg() → handleCall() │ ├─ "eth_getBalance" split on "_" → service="eth", method="getBalance" ├─ serviceRegistry lookup → find BlockChainAPI.GetBalance callback ├─ parsePositionalArguments() → decode JSON params to Go types │ params[0] "0xAlice" → common.Address │ params[1] "latest" → rpc.BlockNumberOrHash └─ callb.call() → invoke Go method via reflection │ ▼Layer 4: API struct execution BlockChainAPI.GetBalance(ctx, 0xAlice, "latest") │ ├─ backend.StateAndHeaderByNumberOrHash("latest") │ → "latest" resolves to BlockNumber(-2) │ → blockchain.CurrentBlock() gets latest block header │ → state.New(header.Root) opens StateDB │ └─ statedb.GetBalance(0xAlice) → reads alice's balance from state trie (Chapter 4) │ ▼Layer 5: Return Balance → hexutil.Big → JSON serialization {"jsonrpc":"2.0","id":1,"result":"0x1234..."} → HTTP response sent back to clientLayer 1: Three transport types
| Transport | Protocol | Subscription support | Typical use |
|---|---|---|---|
| HTTP | Request/response | No | Wallets, scripts, remote access |
| WebSocket | Full-duplex | Yes | DApps needing eth_subscribe |
| IPC | Unix socket | Yes | Local tools (geth attach) |
HTTP is the most common. Each request creates a new handler, disposed after processing — no persistent state:
func (s *Server) serveSingleRequest(ctx context.Context, codec ServerCodec) { h := newHandler(ctx, codec, s.idgen, &s.services, ...) h.allowSubscribe = false // HTTP can't push notifications defer h.close(io.EOF, nil)
reqs, batch, err := codec.readBatch() if batch { h.handleBatch(reqs) } else { h.handleMsg(reqs[0]) }}WebSocket and IPC are persistent connections that support subscriptions — the server can proactively push notifications to the client (e.g., new block arrived).
Anti-abuse limits: HTTP body max 5MB, configurable batch limits control request count and total response size.
Layer 2: Service registry (reflection magic)
At startup, each subsystem registers its API methods with the server:
server.RegisterName("eth", blockChainAPI) // eth namespaceserver.RegisterName("eth", transactionAPI) // same namespace, multiple structsserver.RegisterName("txpool", txPoolAPI) // txpool namespaceserver.RegisterName("debug", debugAPI) // debug namespaceRegisterName() uses reflection to scan all exported methods:
func suitableCallbacks(receiver reflect.Value) map[string]*callback { typ := receiver.Type() for m := 0; m < typ.NumMethod(); m++ { method := typ.Method(m) if method.PkgPath != "" { continue // unexported method, skip } cb := newCallback(receiver, method.Func) name := formatName(method.Name) // GetBalance → getBalance callbacks[name] = cb }}Requirements for a Go method to become an RPC callback:
- Must be exported (uppercase first letter)
- Optionally takes
context.Contextas first parameter - Returns at most two values: optional result + optional
error
Name transformation: GetBalance → getBalance. Combined with namespace: eth + _ + getBalance = eth_getBalance.
Multiple structs can share the same namespace:
Methods under "eth" namespace come from multiple structs: EthereumAPI → eth_gasPrice, eth_syncing BlockChainAPI → eth_getBalance, eth_call, eth_blockNumber TransactionAPI → eth_sendRawTransaction, eth_getTransactionByHashLayer 3: Method dispatch
When a request reaches the handler:
func (h *handler) handleCall(cp *callProc, msg *jsonrpcMessage) *jsonrpcMessage { // 1. Subscription request? if msg.isSubscribe() { return h.handleSubscribe(cp, msg) }
// 2. Unsubscribe? if msg.isUnsubscribe() { callb = h.unsubscribeCb } else { // 3. Normal call: look up callback callb = h.reg.callback(msg.Method) // "eth_getBalance" → split on "_" // → service "eth", method "getBalance" // → look up in registry }
// 4. Parse arguments args, err := parsePositionalArguments(msg.Params, callb.argTypes) // JSON params array → Go type list
// 5. Invoke via reflection answer := h.runMethod(cp.ctx, msg, callb, args) // build arg list (receiver + context + args) // reflect.Value.Call()}Block number sentinel values
Many RPC methods accept a block identifier. Special strings map to negative sentinel values:
"earliest" → BlockNumber(-5) → earliest available block"safe" → BlockNumber(-4) → safe block (CL confirmed)"finalized" → BlockNumber(-3) → finalized block (CL finalized)"latest" → BlockNumber(-2) → latest block"pending" → BlockNumber(-1) → pending stateResolved to actual headers in EthAPIBackend:
func (b *EthAPIBackend) HeaderByNumber(ctx context.Context, number rpc.BlockNumber) (*types.Header, error) { switch number { case rpc.PendingBlockNumber: return b.eth.miner.Pending() case rpc.LatestBlockNumber: return b.eth.blockchain.CurrentBlock() case rpc.FinalizedBlockNumber: return b.eth.blockchain.CurrentFinalBlock() case rpc.SafeBlockNumber: return b.eth.blockchain.CurrentSafeBlock() case rpc.EarliestBlockNumber: return b.eth.blockchain.GetHeaderByNumber(cutoff) default: return b.eth.blockchain.GetHeaderByNumber(uint64(number)) }}Backend interface: bridge between API and core
All API structs delegate to the Backend interface rather than accessing blockchain, txpool, etc. directly:
API structs → Backend interface → Core subsystems │ ├─ HeaderByNumber() → blockchain ├─ StateAndHeaderBy...() → blockchain + state ├─ SendTx() → txpool ├─ GetPoolTransaction() → txpool ├─ GetEVM() → vm └─ ChainConfig() → paramsWhy this abstraction layer? Because the same API code can serve different backends — full nodes use EthAPIBackend, light clients use LESAPIBackend.
Q2: How does eth_call work, and why is it one of the most important RPC methods?
Why it matters
Almost all DApp read operations go through eth_call:
Check token balance → eth_call(balanceOf(address))Get DEX quote → eth_call(getAmountsOut(amount, path))Simulate transaction → eth_call(transfer(to, amount))Check contract state → eth_call(any view function)Its core promise: execute a transaction against specified block state, return the result, but never modify the chain.
Complete execution path
eth_call(args, blockNrOrHash, stateOverride, blockOverrides) │ ▼Step 1: Resolve block, open StateDB blockNrOrHash = "latest" → current chain head backend.StateAndHeaderByNumberOrHash() → state.New(header.Root) → StateDB at that block │ ▼Step 2: Apply block overrides (optional) Modify EVM block context: block number, timestamp, base fee, etc. → In-memory only, nothing written to disk │ ▼Step 3: Apply state overrides (optional) Modify account state: balance, nonce, code, storage → In-memory only, nothing written to disk │ ▼Step 4: Set up gas pool and timeout Gas pool = RPCGasCap (default 50 million) or unlimited Timeout = RPCEVMTimeout (default 5 seconds) │ ▼Step 5: Execute TransactionArgs → core.Message Create EVM instance core.ApplyMessage() ← same execution path as real transactions (Chapter 6) A goroutine monitors timeout, cancels EVM if exceeded │ ▼Step 6: Return ├─ Success → return EVM output bytes (hex encoded) ├─ REVERT → return revert reason (decoded error message) └─ Other error → return errorOverride mechanism: temporarily modifying world state
This is eth_call’s most powerful and least intuitive feature.
State Override — temporarily replace any account’s state:
type OverrideAccount struct { Nonce *hexutil.Uint64 Code *hexutil.Bytes // replace contract code Balance *hexutil.Big // replace balance State map[common.Hash]common.Hash // completely replace storage StateDiff map[common.Hash]common.Hash // incremental storage diff MovePrecompileTo *common.Address // move precompile contract}Practical use cases:
// "If alice had 100 ETH, would this transaction succeed?"{ "0xAlice": { "balance": "0x56BC75E2D63100000" }}
// "If this contract's code were the new version, what would happen?"{ "0xContract": { "code": "0x6080604052..." }}
// "If this storage slot's value changed, what would happen?"{ "0xContract": { "stateDiff": { "0x0000...0001": "0x0000...00FF" } }}Block Override — temporarily modify block context:
type BlockOverrides struct { Number *hexutil.Big // custom block number Time *hexutil.Uint64 // custom timestamp GasLimit *hexutil.Uint64 // custom gas limit BaseFeePerGas *hexutil.Big // custom base fee // ...}Use case: simulate “if it were tomorrow, could this timelock contract unlock?”
All overrides take effect in memory only, discarded after execution, never written to disk.
Gas and timeout control
// Gas limitif globalGasCap == 0 { gp.AddGas(math.MaxUint64) // unlimited gas} else { gp.AddGas(globalGasCap) // default 50,000,000}
// Timeout controlif timeout > 0 { ctx, cancel = context.WithTimeout(ctx, timeout) // default 5 seconds}// A goroutine cancels EVM execution on timeoutWhy the limits? Because eth_call costs no real money — callers can pass arbitrarily large gas limits. Without limits, malicious callers could craft extremely long-running calls to DoS the node.
Comparison with eth_sendRawTransaction
eth_call eth_sendRawTransactionPurpose Read/simulate Submit real transactionModifies chain No Yes (tx enters txpool → gets mined)Requires sig No Yes (tx must be signed)Costs gas No (free) Yes (deducts real ETH)Overrides Supported Not supportedExecution Synchronous, returns result Async, returns tx hashConcrete example
Query alice’s balance in the USDC contract:
1. Client constructs call data: balanceOf(address) selector = 0x70a08231 Argument = alice's address (left-padded to 32 bytes) data = 0x70a08231000000000000000000000000AliceAddress...
2. Send eth_call: {to: "0xUSDC", data: "0x70a08231..."}
3. Geth executes: Open "latest" block's StateDB → Create EVM → CALL to USDC contract address → EVM executes balanceOf bytecode → SLOAD reads alice's balance storage slot → RETURN returns balance value
4. Return result: "0x0000000000000000000000000000000000000000000000000000000005F5E100" = 100,000,000 (100 USDC, since USDC has 6 decimals)No state was modified, no gas was spent, no transaction was created.
Some information may be outdated






