지난주 커밋한 상태 위에서 누가 다음 블록을 만들고, 만든 블록은 어떻게 정본이 되는가: 그 지시가 geth 밖에서 들어오는 자리를 코드로 따라갑니다. eth/catalyst, miner, consensus/beacon입니다.
기준 코드: go-ethereum ca1f2e4d3

지도

오늘 다루는 구간: 블록 생성과 합의 경계

지갑 서명: 1주

개인키로 서명

RPC / p2p: 2주

접수와 전파

txpool: 2주

대기실, 검증

EVM 실행: 3주

옵코드 하나씩

상태: 4주

StateDB→디스크

블록 · 합의: 오늘

조립과 정본 지정

4주차는 Commit에서 멈췄다. 바뀐 노드가 디스크에 앉고 루트 32바이트가 남았다. 그 루트 위에서 다음 블록을 만들라고 하는 건 누구고, 만든 블록은 어떻게 모두의 진실이 되는가. 그 지시는 geth 밖에서 온다.

오늘 답할 질문 세 개: ① 누가, 언제 이 루트 위에 블록을 만들라고 하나 ② 그 블록은 어떻게 만들어지나 (EVM을 부르던 miner 루프의 위아래에 뭐가 있나) ③ 받은 블록은 어디로 들어와 검증되고, 어떻게 정본이 되나

consensus/beacon/consensus.go

노드 한 대는 두 프로그램이고, 사이에 Engine API가 있다

CL · 비콘 노드 (Lighthouse, Prysm …)

12초 슬롯 시계, 검증자 스케줄, 증명 집계
포크 초이스 → 어느 블록이 head인가 결정
비콘 블록 가십 (실행 페이로드를 안에 담아 전파)
geth에게 지시: 실행해봐 / 이걸 head로 / 다음 블록 만들어

EL · geth (Nethermind, Besu, Reth …)

txpool, EVM, StateDB, 트라이, 디스크: 지금까지 본 것 전부
devp2p로 tx 전파 (블록 전파는 이제 CL 몫)
지시받은 블록 실행 → 루트 대조 → VALID / INVALID
지시받으면 txpool에서 블록 조립. 시간 감각 없음

Engine API · :8551 · JWT 인증: JSON-RPC지만 8545와 별개 포트다. node/defaults.goDefaultAuthPort = 8551.

첫 주에 그림으로만 봤던 분업 구조다. 오늘은 그 사이의 화살표를 코드로 연다. geth가 합의를 하지 않는다는 말의 증거가 합의 엔진 자리에 그대로 남아 있다.

func (beacon *Beacon) Prepare(...) error {
    header.Difficulty = beaconDifficulty   // 0
    return nil
}

func (beacon *Beacon) Seal(...) error {
    // seal verification is done by the external consensus engine
    return nil
}

func (beacon *Beacon) Finalize(...) {
    for _, w := range body.Withdrawals {
        state.AddBalance(w.Address, amount)
    }
    // No block reward (consensus layer instead)
}
  • Prepare: PoW 목표가 없다. Difficulty는 상수 0이다.
  • Seal: 봉인은 CL의 서명·투표가 대신한다. 함수가 비어 있다.
  • Finalize: 블록 보상이 없고 인출만 반영한다. 주석이 이유를 직접 말한다.
eth/catalyst/api.go

Engine API 진입점은 eth/catalyst/api.go다

func Register(stack *node.Node, backend *eth.Ethereum) error {
    stack.RegisterAPIs([]rpc.API{{
        Namespace:     "engine",
        Service:       NewConsensusAPI(backend),
        Authenticated: true,
    }})
}
하는 일

engine 네임스페이스로 서비스를 등록한다. 2주차에 eth_sendRawTransaction을 등록하던 그 suitableCallbacks가 이 서비스의 메서드도 똑같이 훑어 등록한다.

여기서 드러나는 것

다른 건 Authenticated: true 하나다. JWT가 붙은 8551 포트로만 노출된다.

바깥에서 보이는 이름은 버전이 붙어 있지만, 본체는 소문자 함수 세 개다.

engine_forkchoiceUpdatedV1~V3

forkchoiceUpdated(...)
이게 새 head다 (+ 옵션: 다음 블록 만들 준비해)

engine_getPayloadV1~V5

getPayload(id, full)
아까 만들라고 한 블록 내놔

engine_newPayloadV1~V4

newPayload(params, ...)
이 블록 실행해서 유효한지 봐

  • 초독 규칙: V1/V2/V3 래퍼는 "이 포크에서는 이 필드가 있어야 한다"는 방어 검사만 하고 소문자 함수로 합류한다. 오늘은 소문자 함수 세 개만 읽는다.
  • 오늘 건너뛰는 것: GetBlobs, GetPayloadBodies, ExchangeCapabilities는 6주차 또는 생략이다.
순서

한 슬롯 동안 CL과 geth가 주고받는 대화

CL이 호출하고 geth가 응답한다. 이 순서가 곧 오늘의 목차다.

  1. 1
    newPayload(블록 N)

    ← 실행 + 상태 루트 대조 → VALID. 네트워크에서 받은 블록을 검증만 해둔다 (head는 안 바꿈)

  2. 2
    forkchoiceUpdated(head=N, safe, finalized)

    ← SetCanonical → 정본 갱신. 포크 초이스 결과를 통보한다. head가 바뀌면 여기서 reorg

  3. 3
    forkchoiceUpdated(head=N, attrs{timestamp, feeRecipient…})

    ← buildPayload 시작 → PayloadID. 내가 다음 슬롯 제안자면 "블록 만들어"까지 같이 보낸다

  4. 4
    getPayload(PayloadID)

    ← Resolve() → ExecutionPayload. 슬롯 시각 직전에 최선의 블록을 회수해 서명·전파한다

전부 CL이 먼저 말을 건다. geth가 CL을 부르는 방향은 없다. Engine API는 단방향 서버다.

eth/catalyst/api.go

forkchoiceUpdated는 head를 지정하고, attrs가 있으면 빌딩을 시작한다

block := GetBlockByHash(update.HeadBlockHash)
if block == nil { BeaconSync(); return SYNCING }   // 모르는 블록
if 정본이 아니면 { SetCanonical(block) }            // head 지정, 필요시 reorg
SetFinalized(...); SetSafe(...)

if payloadAttributes != nil {              // 다음 블록 만들어
    args := BuildPayloadArgs{Parent: head, Timestamp, FeeRecipient,
                             Random, Withdrawals, BeaconRoot}
    id := args.Id()                        // 인자 해시 앞 8바이트
    payload := Miner().BuildPayload(args)
    localBlocks.put(id, payload)
    return valid(&id)                      // 응답에 PayloadID
}
return valid(nil)
① 정본 지정: 세 해시가 전부 CL에서 온다

geth에는 "가장 무거운 체인" 같은 규칙이 없다. 받은 head가 정본 트리 안에 있는지만 검사하고 그대로 따른다.

② 빌드 요청: CL이 정해서 넘겨주는 값

attrs가 붙어 있으면 같은 함수가 블록 빌딩까지 시작하고 PayloadID를 돌려준다.

  • SetCanonical이 reorg와 writeHeadBlock을 한다: finalized·safe는 RPC의 finalized/safe 블록 태그가 되고, setHead가 그 아래로 되감지 못하게 하는 하한선이 된다.
  • Timestamp: 슬롯 시각이다. geth는 시계를 안 본다.
  • SuggestedFeeRecipient: 수수료 받을 주소.
  • Random: RANDAO다. 헤더의 MixDigest가 되고, PREVRANDAO 옵코드의 출처가 여기다.
  • Withdrawals, BeaconRoot: 인출 목록과 EIP-4788 값.
  • 헤더 입력값의 절반이 CL 몫이다: geth는 부모에서 계산되는 값과 실행 결과만 채운다.
beacon/engine/types.go

페이로드는 블록의 다른 표현이고, 헤더 필드는 넷으로 나뉜다

CL이 attrs로 준다

ParentHash ← head 해시 · Coinbase ← SuggestedFeeRecipient · MixDigest ← Random · Time ← Timestamp · Withdrawals, ParentBeaconRoot

부모 헤더에서 계산 (prepareWork)

Number = parent + 1 · GasLimit = CalcGasLimit(parent, GasCeil) · BaseFee = eip1559.CalcBaseFee(parent) · ExcessBlobGas = CalcExcessBlobGas(parent)

tx를 실행한 결과 (2~4주차)

Root ← IntermediateRoot (4주) · ReceiptHash, Bloom, GasUsed ← 영수증 (3주) · TxHash ← 담긴 tx 목록 (2주 pending) · BlobGasUsed, RequestsHash

상수·설정

Difficulty = 0 (beacon.Prepare) · Nonce = 0 · UncleHash = 빈 목록 해시 · Extra ← --miner.extradata

// engine.ExecutableDataToBlock: newPayload 첫 줄
block, err := ExecutableDataToBlockNoHash(data, hashes, beaconRoot, requests)
if block.Hash() != data.BlockHash {
    return nil, fmt.Errorf("blockhash mismatch ...")
}
하는 일

ExecutableData는 헤더 필드 + tx 바이트 + 인출을 JSON으로 편 것이다. 받는 쪽은 그걸로 블록을 다시 조립해 해시를 낸다.

여기서 드러나는 것

CL이 보낸 blockHash와 다르면 실행도 안 해보고 INVALID다. 하지만 위 네 묶음 중 실행 결과 세 개는 실행 후에야 확인할 수 있어서, ValidateState까지 가야 진짜 검증이 끝난다.

miner/payload_building.go

buildPayload는 빈 블록을 먼저 만들고, 12초 동안 더 좋은 블록으로 갱신한다

empty := generateWork(noTxs: true)   // 1) 빈 블록 먼저
payload := newPayload(empty, args.Id())

go func() {                          // 2) 계속 갱신
    for {
        select {
        case <-timer.C:              // 즉시 1회, 이후 2초마다
            r := generateWork(full)
            payload.update(r)        // 수수료 더 크면 교체
        case <-payload.stop:  return // getPayload가 가져감
        case <-endTimer.C:    return // 12초 지나면 포기
        }
    }
}()
왜 빈 블록부터인가

슬롯을 놓치는 것(빈손)보다 빈 블록이라도 내는 게 낫다. 빈 블록은 상태 변화 없이 헤더만 만들면 되니 빠르다.

이렇게 만든 이유

안전한 답부터 확보하고, 시간이 남는 만큼 개선하는 구조다.

// payload.update: 교체 조건
if payload.full == nil || r.fees.Cmp(payload.fullFees) > 0 {
    payload.full = r.block
    ...
}
  • 수수료 합이 더 클 때만 교체한다: 주석 그대로, 엉클 보상이 없어진 뒤로 tx 수수료가 유일한 비교 지표다.
miner/worker.go

generateWork는 부모 루트 위에 StateDB를 열고 블록을 조립한다

호출 사슬은 generateWork()prepareWork()makeEnv()fillTransactions()FinalizeAndAssemble()이다.

prepareWork: 헤더 초안 만들기

ParentHash, Number = parent+1 · GasLimit = CalcGasLimit(parent, GasCeil) · Time, Coinbase ← CL 인자 · BaseFee = eip1559.CalcBaseFee(parent) · MixDigest = Random · engine.Prepare → Difficulty = 0 · Cancun: ExcessBlobGas, ParentBeaconRoot

makeEnv: 실행 환경 열기

state := chain.StateAt(parent.Root): 지난주 끝에 예고한 그 줄이다. 부모 루트 위에 빈 StateDB를 연다. evm := vm.NewEVM(blockCtx, state, cfg): 3주차의 그 객체다. 빌더도 결국 실행기다

시스템 콜: tx 전에 실행되는 것

ProcessBeaconBlockRoot (EIP-4788) · ProcessParentBlockHash (EIP-2935, Prague) · 사용자 tx가 아닌데 상태를 바꾸는 호출이고, 실행 순서상 블록의 첫 상태 변화다

fillTransactions → FinalizeAndAssemble

txpool.Pending → 정렬 → 하나씩 실행 · 인출 반영 → IntermediateRoot → 블록. 다음 두 절이다

generateWork는 한 번만 불리지 않는다. 빈 블록 1회 + Recommit마다 1회씩 반복 호출되고, 호출될 때마다 StateDB를 부모 루트에서 새로 연다. 이전 시도의 상태를 재사용하지 않는다: 4주차의 "일회용 작업 사본"이 여기서 힘을 발휘한다.

miner/worker.go

fillTransactions는 고르고, applyTransaction은 실행한다

pending := txpool.Pending(filter{MinTip, BaseFee})
        // 2주차의 pending 맵. queue는 안 옴
txs := newTransactionsByPriceAndNonce(pending)
        // 계정 안은 nonce 순, 계정 사이는 팁 순
commitTransactions(env, txs, interrupt)
        // 하나씩 꺼내 실행. 가스 다 쓰거나 interrupt면 끝
  • Pending(filter)는 LegacyPool의 pending 맵만 준다: queue는 안 온다. 2주차의 서브풀 구조가 여기까지 이어진다.
  • transactionsByPriceAndNonce: 계정 안에서는 nonce 순, 계정 사이에서는 유효 팁 순 힙이다.
  • Pop은 계정 자체를 포기하는 것이다: nonce 갭이 생기니 그 뒤도 못 쓴다.
func (miner *Miner) applyTransaction(env, tx) {
    snap := env.state.Snapshot()
    receipt, err := core.ApplyTransaction(env.evm, env.gasPool,
                       env.state, env.header, tx)
    if err != nil { env.state.RevertToSnapshot(snap) }
    return receipt, err
}
하는 일

3주차에 잘라 보여준 그 루프의 원래 자리다. core.ApplyTransaction → StateTransition → 인터프리터 루프로 내려간다.

여기서 드러나는 것

Snapshot과 RevertToSnapshot이 4주차 journal의 그 함수다. 옵코드가 StateDB에 즉시 쓰고, tx 끝에 Finalise가 불린다.

빌더는 무엇을 넣을지 고르고, 검증자는 들어 있는 것을 돌린다. 고르는 코드만 다르고 실행 코드는 같다.

consensus/beacon/consensus.go · miner/payload_building.go

FinalizeAndAssemble이 블록을 완성하고, getPayload가 회수한다

func (beacon *Beacon) FinalizeAndAssemble(...) {
    beacon.Finalize(...)                      // 인출 잔액 반영
    header.Root = state.IntermediateRoot(true)
    return types.NewBlock(header, body, receipts)
}
// getPayload(id) → localBlocks.get(id) → Resolve()
func (payload *Payload) Resolve() {
    close(payload.stop)           // 빌더 고루틴 종료
    if payload.full != nil { return payload.full }
    return payload.empty
}
  • 지난주와 연결되는 지점: IntermediateRoot가 헤더 Root로 들어간다고만 했는데, 실제로 호출되는 자리가 여기다. 빌더 쪽에서는 Root를 계산해서 헤더에 적고, 검증 쪽에서는 계산해서 헤더와 비교한다.
  • NewBlock의 StackTrie: tx 루트와 영수증 루트를 만드는 데 쓴다.
  • ExecutionPayload의 내용물: 헤더 필드 + tx 바이트 + 인출, 그리고 blob 번들과 requests.

이 시점까지 이 블록은 geth 로컬 DB에 없다. 정본이 되려면 CL이 다시 newPayload → forkchoiceUpdated를 보내야 한다. 자기가 만든 블록도 남의 블록과 똑같은 입구로 들어온다.

eth/catalyst/api.go

newPayload는 재실행과 루트 대조의 입구다

block := ExecutableDataToBlock(params)         // 해시 대조
if 이미 아는 블록 { return VALID }
if 부모 없음     { 보류; return SYNCING }

err := InsertBlockWithoutSetHead(block)
   // → Process(block)         tx 전부 재실행
   // → ValidateState(block)   IntermediateRoot vs header.Root
   // → writeBlockWithState    저장. head는 안 건드림

if err != nil { return INVALID }   // 무효 조상 목록에 기억
return VALID
하는 일

받은 블록의 tx를 전부 재실행하고 상태 루트를 대조한 뒤 저장한다. 3·4주차에서 본 Process와 ValidateState가 그대로 불린다.

여기서 드러나는 것

함수 이름이 이미 답이다: InsertBlockWithoutSetHead. 실행하고 저장하되 head는 건드리지 않는다.

  • 응답 네 가지: VALID(DB에 썼지만 head 아님) · INVALID(조상 목록에 기억) · SYNCING(부모나 상태 없음) · ACCEPTED(지금 실행 불가).
  • 왜 head 지정은 안 하나: geth에는 어느 블록이 이기는지 판단할 정보(증명, 투표)가 없다. 받은 블록은 전부 실행해 저장만 하고, head는 CL이 forkchoiceUpdated로 알려줄 때까지 기다린다.
  • 주석이 직접 말한다: payload execution will not permit reorgs. 형제 블록이 여럿 들어와도 head가 요동치지 않게 한다.

머지 이전에 InsertChain 하나가 하던 일이 둘로 갈라졌다. 실행·저장은 newPayload, 정본 지정은 forkchoiceUpdated. 오늘 제목의 "합의 경계"가 바로 이 두 호출 사이의 선이다.

eth/catalyst/simulated_beacon.go

첫 과제의 geth --dev는 이 네 호출을 혼자 흉내 낸 것이다

func (c *SimulatedBeacon) sealBlock(...) {
    // ① 다음 블록 만들어
    fc := c.engineAPI.forkchoiceUpdated(state, &attrs)
    // ② 만든 거 내놔
    env := c.engineAPI.getPayload(*fc.PayloadID)
    // ③ 실행해서 검증해
    c.engineAPI.newPayload(env.ExecutionPayload, ...)
    // ④ 이걸 head로
    c.engineAPI.forkchoiceUpdated(state{head: payload.BlockHash}, nil)
}
  • 1주차 과제에서 블록이 만들어진 이유: geth 안에 내장된 시뮬레이션 비콘 노드가 Engine API 함수를 직접 불러줬기 때문이다.
  • 합의가 정말로 빠져 있다는 증거: dev 모드조차 CL 역할을 흉내 내야 돌아간다.
  • RPC 계층 없이 소문자 함수를 직접 부른다: 로그를 심으면 네 단계 순서를 --dev 하나로 관찰할 수 있다.
  • 32블록마다 finalized를 올린다: devEpochLength = 32. 실제 CL의 에포크 단위 최종성 흉내다.
  • 실제 CL은: 이 네 호출을 12초 슬롯 스케줄에 맞춰, 네트워크에서 온 블록과 섞어서 보낸다.
맥락

geth 코드 밖: MEV-Boost와 외부 빌더

검증자 CL

헤더+입찰가 요청

mev-boost

사이드카

릴레이

헤더 반환

외부 빌더

자체 EL로 블록 조립

로컬 빌딩은 옵션 중 하나다. 메인넷 블록 대부분은 위 경로로 만들어진다.

geth 입장에서 달라지는 것

CL은 여전히 forkchoiceUpdated(attrs)로 로컬 빌드를 시킨다: 릴레이가 응답하지 않을 때의 보험이다. CL이 릴레이 블록을 고르면 geth의 로컬 payload는 그냥 버려진다 (getPayload가 오지 않는다).

검증 경로는 그대로다

하지만 최종적으로 그 블록도 newPayload로 geth에 들어와 실행·검증된다. 빌더가 누구든 검증 경로는 같다.

  • 왜 이 구조가 생겼나: 블록 안 tx 순서로 이익을 얻는 MEV 경쟁이 검증자에게 집중되면 검증자 중앙화 압력이 된다. 빌더-제안자 분리(PBS)는 순서 짜기를 전문 빌더에게 넘기고 검증자는 입찰가만 보고 고르게 한다.
  • MEV-Boost는 프로토콜 밖이다: 이를 구현한 사이드카고, geth 코드에는 등장하지 않는다.
정리

오늘 정리와 다음 주 주제

3주차에 표로만 보여준 "tx를 다루는 세 시점"이 이제 각각 어느 함수인지 다 채워졌다.

검사: 멤풀 진입 시, 모든 노드

txpool 진입(2주차)과 txpool.ValidateTransaction. 상태를 읽기만 하고 EVM은 없다. 그리고 오늘 fillTransactions가 이 pending을 가져간다

실행: 그 슬롯의 빌더

forkchoiceUpdated(attrs) → buildPayload → generateWork → applyTransaction → FinalizeAndAssemble → getPayload. CL이 시키고, geth가 만들고, CL이 가져간다

재실행: 블록을 받은 모든 풀노드

newPayload → InsertBlockWithoutSetHead → Process → ValidateState. 들어 있는 것을 돌리고 Root를 확인한다. head 지정은 forkchoiceUpdated가 따로 한다

func handleNewBlock(...) error {
    return errors.New("block broadcasts disallowed")
}

6주차 · p2p의 나머지 절반과 EIP-4844: 오늘 newPayload로 들어온 블록은 CL에서 왔고, geth의 p2p는 블록 전파를 아예 거부한다. 그럼 지도의 p2p 칸은 이제 무엇을 나르나: tx다. 2주차에 화살표 하나로만 넘겼던 p2p 경로(txFetcher가 그 입구)를 열고, getPayload 응답에 붙어 있던 BlobsBundle과 newPayload 인자의 versionedHashes를 EIP-4844로 묶는다. 새 칸은 없다. 첫 주에 그림으로 그린 tx의 일생을 함수 이름으로 다시 이어 지도를 닫는다.