Geth의 이더리움 PoS 합의 알고리즘
목차
- 이더리움 PoS 합의 알고리즘 개요
- 포크 선택 규칙 처리
- 체인 재구성 (Reorg) 처리
- 에포크 및 슬롯 관리
- RANDAO 관련 로직
1. 이더리움 PoS 합의 알고리즘 개요
이더리움은 Beacon 체인 기반의 지분증명(Proof of Stake, PoS) 합의 알고리즘을 사용함.
합의 프로토콜에는 에포크(epoch)와 슬롯(slot) 개념이 있음.
1개 에포크는 32개 슬롯 (약 6.4분)으로 구성되고 1개 슬롯은 12초.
각 슬롯마다 네트워크의 검증인(validator) 중 한 명이 블록 제안자로 무작위 선택되어 블록을 제안하고, 나머지 검증인들은 해당 슬롯의 블록에 대한 승인 투표(attestation)를 함.
빈 슬롯도 발생할 수 있으며, 이 경우 체인은 다음 슬롯으로 넘어감.
빈 슬롯은 컴퓨터가 맛이 가거나 네트워크 지연으로 차질이 생기는 경우.
PoS 합의의 포크 선택 규칙은 LMD-GHOST(Latest Message Driven - Greediest Heaviest Observed SubTree) 알고리즘과 Casper FFG 최종화(finality) 알고리즘을 돌림.
검증인들의 최신 투표 메시지에 기반하여 가장 무거운 서브트리를 선택하는 LMD-GHOST를 사용하되, 마지막으로 최종화된 체크포인트 블록의 자손들만 유효한 후보로 간주하도록 Casper FFG 규칙이 LMD-GHOST를 제약함. (Casper FFG 가 LMD-GHOST에게 자중해! 를 시전함)
Casper FFG의 투표를 통해 에포크 단위로 블록의 정당성(Justification)과 최종성(Finalization)이 확보.
한 번 최종화(finalized)된 블록 이전의 체인 분기는 영구적으로 포크 선택 대상에서 제외.
최종화된 체크포인트 이후 구간에서는 LMD-GHOST에 따라 현재 가장 높은 투표 가중치를 지닌 체인 헤드가 선택.
이러한 구조 덕분에 PoS 이더리움은 체인 재구성 깊이(reorg depth)가 제한됨 (최종화된 블록 이전으로는 재구성 불가).
랜덤 비콘(randomness beacon)으로서 RANDAO 메커니즘을 사용.
각 슬롯의 블록 제안자는 자신의 개인키로 현재 에포크 번호에 서명한 값을 Randao 리빌(reveal)로 블록에 포함, 이를 Beacon 체인이 누적하여 난수 시드(seed)를 생성.
블록 제안자의 Randao 서명이 올바르지 않으면 블록은 무효.
수집된 난수는 다음 에포크의 검증인 셔플링 등에 활용.
PoS 이더리움 Merge 이후 실행층(EVM)에서도 이 Randao 값을 활용할 수 있도록 합의가 변경됨? (각 클라이언트의 RANDAO 구현 로직에서 자세히 설명)
- 이더리움 PoS 합의 알고리즘은 Beacon 체인의 슬롯/에포크 스케줄에 따라 검증인들이 블록 제안 및 투표를 수행
- Casper FFG로 최종성을 부여
- LMD-GHOST로 비최종화 구간의 최상위 체인 헤드를 선택
2. 포크 선택 규칙 처리
Geth는 실행 클라이언트(Execution Client)로서, PoS 합의 프로토콜의 포크 선택 결과를 엔진 API를 통해 컨센서스 클라이언트로부터 전달받아 처리.
Merge 이후 Geth에는 engine_forkchoiceUpdated
등의 엔진 API RPC 핸들러가 추가됨, Beacon 체인 합의로 새로운 체인 헤드를 선택할 때 이를 받아들여 체인 상태를 전환.
Geth의 eth/catalyst/api.go
모듈의 ConsensusAPI
구조체에 이러한 엔진 API 핸들러들이 구현되어 있음.
예를 들어 ConsensusAPI.ForkchoiceUpdatedV1(...)
메서드는 컨센서스 레이어로부터 포크 선택 상태와 신규 페이로드 지시를 입력받아, api.forkchoiceUpdated(...)
함수를 호출, Geth의 체인 헤드를 갱신하고 (필요시) 블록 생성 프로세스를 시작.
fork 버전에 따라 forkchoiceUpdated호출 버전이 달라짐.
fork 버전에 따라 호출 분기
switch fork {
case "deneb", "electra":
method = "engine_forkchoiceUpdatedV3"
case "capella":
method = "engine_forkchoiceUpdatedV2"
default:
method = "engine_forkchoiceUpdatedV1"
}
forkchoiceUpdated(…) 코드
// forkchoiceUpdated는 포크초이스 업데이트 요청을 처리하는 내부 메서드입니다.
// update: 포크초이스 상태 (헤드, 완료, 안전 블록 해시)
// payloadAttributes: 페이로드 생성을 위한 속성들
// payloadVersion: 페이로드 버전
// payloadWitness: 위트니스 수집 여부
func (api *ConsensusAPI) forkchoiceUpdated(update engine.ForkchoiceStateV1, payloadAttributes *engine.PayloadAttributes, payloadVersion engine.PayloadVersion, payloadWitness bool) (engine.ForkChoiceResponse, error) {
// 동시 접근 방지를 위한 락 획득
api.forkchoiceLock.Lock()
defer api.forkchoiceLock.Unlock()
log.Trace("Engine API 요청 수신", "method", "ForkchoiceUpdated", "head", update.HeadBlockHash, "finalized", update.FinalizedBlockHash, "safe", update.SafeBlockHash)
// 헤드 블록 해시가 빈 값인지 확인
if update.HeadBlockHash == (common.Hash{}) {
log.Warn("빈 해시로 포크초이스 업데이트 요청됨")
return engine.STATUS_INVALID, nil // TODO(karalabe): 왜 누군가 이것을 보내는가?
}
// 비콘 클라이언트가 오프라인 상태가 되면 사용자에게 경고하기 위해 마지막 업데이트 시간을 저장
api.lastForkchoiceLock.Lock()
api.lastForkchoiceUpdate = time.Now()
api.lastForkchoiceLock.Unlock()
// 데이터베이스에서 해당 블록이 존재하는지 확인합니다.
// 없다면 동기화를 트리거하거나 포크초이스 업데이트를 거부해야 합니다.
block := api.eth.BlockChain().GetBlockByHash(update.HeadBlockHash)
if block == nil {
// 이 블록이 이전에 무효화되었다면, 여기서도 계속 거부합니다
if res := api.checkInvalidAncestor(update.HeadBlockHash, update.HeadBlockHash); res != nil {
return engine.ForkChoiceResponse{PayloadStatus: *res, PayloadID: nil}, nil
}
// 헤드 해시가 알려지지 않은 경우 (newPayload 요청에서 제공되지 않음)
// 헤더를 해결할 수 없으므로 할 수 있는 일이 많지 않습니다.
header := api.remoteBlocks.get(update.HeadBlockHash)
if header == nil {
log.Warn("알려지지 않은 헤드로 포크초이스 요청됨", "hash", update.HeadBlockHash)
return engine.STATUS_SYNCING, nil
}
// 완료된 해시가 알려져 있다면, 다운로더가 처음부터 더 많은 데이터를
// 프리저로 이동시킬 수 있도록 지시할 수 있습니다.
finalized := api.remoteBlocks.get(update.FinalizedBlockHash)
// 과거 newPayload 요청을 통해 광고된 헤더입니다. 해당 헤더로 동기화를 시작합니다.
context := []interface{}{"number", header.Number, "hash", header.Hash()}
if update.FinalizedBlockHash != (common.Hash{}) {
if finalized == nil {
context = append(context, []interface{}{"finalized", "unknown"}...)
} else {
context = append(context, []interface{}{"finalized", finalized.Number}...)
}
}
log.Info("새로운 헤드로 동기화 요청됨", context...)
if err := api.eth.Downloader().BeaconSync(api.eth.SyncMode(), header, finalized); err != nil {
return engine.STATUS_SYNCING, err
}
return engine.STATUS_SYNCING, nil
}
// 블록이 로컬에 알려져 있으므로, 비콘 클라이언트가
// 머지 이전으로 되돌리려 시도하지 않는지 건전성 검사를 합니다.
if block.Difficulty().BitLen() > 0 && block.NumberU64() > 0 {
ph := api.eth.BlockChain().GetHeader(block.ParentHash(), block.NumberU64()-1)
if ph == nil {
return engine.STATUS_INVALID, errors.New("난이도 확인을 위한 부모 블록 사용 불가")
}
// 부모가 이미 PoS이고 현재 블록이 PoW라면 오류
if ph.Difficulty.Sign() == 0 && block.Difficulty().Sign() > 0 {
log.Error("부모 블록이 이미 post-ttd 상태", "number", block.NumberU64(), "hash", update.HeadBlockHash, "diff", block.Difficulty(), "age", common.PrettyAge(time.Unix(int64(block.Time()), 0)))
return engine.ForkChoiceResponse{PayloadStatus: engine.INVALID_TERMINAL_BLOCK, PayloadID: nil}, nil
}
}
// 유효한 응답을 생성하는 헬퍼 함수
valid := func(id *engine.PayloadID) engine.ForkChoiceResponse {
return engine.ForkChoiceResponse{
PayloadStatus: engine.PayloadStatusV1{
Status: engine.VALID,
LatestValidHash: &update.HeadBlockHash,
},
PayloadID: id,
}
}
// 블록이 정규 체인에 없다면 헤드로 설정
if rawdb.ReadCanonicalHash(api.eth.ChainDb(), block.NumberU64()) != update.HeadBlockHash {
// 블록이 정규가 아니므로 헤드로 설정합니다.
if latestValid, err := api.eth.BlockChain().SetCanonical(block); err != nil {
return engine.ForkChoiceResponse{PayloadStatus: engine.PayloadStatusV1{Status: engine.INVALID, LatestValidHash: &latestValid}}, err
}
} else if api.eth.BlockChain().CurrentBlock().Hash() == update.HeadBlockHash {
// 지정된 헤드가 로컬 헤드와 일치하는 경우, 아무것도 하지 않고
// 페이로드 생성을 계속합니다. 몇 개의 슬롯이 누락되고
// 슬롯에서 페이로드 생성이 요청되는 특별한 코너 케이스입니다.
} else {
// 헤드 블록이 이미 정규 체인에 있다면, 비콘 클라이언트가
// 아마도 재동기화 중일 것입니다. 업데이트를 무시합니다.
log.Info("이전 헤드로의 비콘 업데이트 무시", "number", block.NumberU64(), "hash", update.HeadBlockHash, "age", common.PrettyAge(time.Unix(int64(block.Time()), 0)), "have", api.eth.BlockChain().CurrentBlock().Number)
return valid(nil), nil
}
// 노드를 동기화된 상태로 설정
api.eth.SetSynced()
// 비콘 클라이언트가 완료된 블록도 광고했다면, 로컬 체인을
// 최종적이고 완전히 PoS 모드로 표시합니다.
if update.FinalizedBlockHash != (common.Hash{}) {
// 완료된 블록이 정규 트리에 없다면 문제가 있습니다
finalBlock := api.eth.BlockChain().GetBlockByHash(update.FinalizedBlockHash)
if finalBlock == nil {
log.Warn("최종 블록이 데이터베이스에서 사용 불가", "hash", update.FinalizedBlockHash)
return engine.STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("최종 블록이 데이터베이스에서 사용 불가"))
} else if rawdb.ReadCanonicalHash(api.eth.ChainDb(), finalBlock.NumberU64()) != update.FinalizedBlockHash {
log.Warn("최종 블록이 정규 체인에 없음", "number", finalBlock.NumberU64(), "hash", update.FinalizedBlockHash)
return engine.STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("최종 블록이 정규 체인에 없음"))
}
// 완료된 블록 설정
api.eth.BlockChain().SetFinalized(finalBlock.Header())
}
// 안전 블록 해시가 정규 트리에 있는지 확인, 없다면 문제가 있습니다
if update.SafeBlockHash != (common.Hash{}) {
safeBlock := api.eth.BlockChain().GetBlockByHash(update.SafeBlockHash)
if safeBlock == nil {
log.Warn("안전 블록이 데이터베이스에서 사용 불가")
return engine.STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("안전 블록이 데이터베이스에서 사용 불가"))
}
if rawdb.ReadCanonicalHash(api.eth.ChainDb(), safeBlock.NumberU64()) != update.SafeBlockHash {
log.Warn("안전 블록이 정규 체인에 없음")
return engine.STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("안전 블록이 정규 체인에 없음"))
}
// 안전 블록 설정
api.eth.BlockChain().SetSafe(safeBlock.Header())
}
// 페이로드 생성이 요청되었다면, 비콘 클라이언트에 의해 잠재적으로
// 봉인될 새로운 블록을 생성합니다. 페이로드는 나중에 요청될 것이고,
// 그 사이에 임의로 여러 번 교체할 수 있습니다.
if payloadAttributes != nil {
// 페이로드 빌드를 위한 인수 구성
args := &miner.BuildPayloadArgs{
Parent: update.HeadBlockHash,
Timestamp: payloadAttributes.Timestamp,
FeeRecipient: payloadAttributes.SuggestedFeeRecipient,
Random: payloadAttributes.Random,
Withdrawals: payloadAttributes.Withdrawals,
BeaconRoot: payloadAttributes.BeaconRoot,
Version: payloadVersion,
}
id := args.Id()
// 이미 이 작업을 생성하느라 바쁘다면, 두 번째 프로세스를
// 시작할 필요가 없습니다.
if api.localBlocks.has(id) {
return valid(&id), nil
}
// 페이로드 빌드 시작
payload, err := api.eth.Miner().BuildPayload(args, payloadWitness)
if err != nil {
log.Error("페이로드 빌드 실패", "err", err)
return valid(nil), engine.InvalidPayloadAttributes.With(err)
}
// 로컬 블록 캐시에 페이로드 저장
api.localBlocks.put(id, payload)
return valid(&id), nil
}
return valid(nil), nil
}
포크 선택(Fork Choice) 처리를 위해 ForkChoice 구조체를 도입하여 체인 헤드 전환을 관리함.
core.BlockChain
구조체는 forker
필드로 ForkChoice
인스턴스를 유지하며, Geth 실행 시 NewForkChoice(bc, preserveFunc)
를 호출하여 이를 초기화.
이 ForkChoice
는 현재 체인의 정보(ChainReader
인터페이스)와 블록 보존 조건 함수(preserve
함수)를 받아 구성됨.
핵심 메서드로 ReorgNeeded()
를 제공.
ForkChoice.ReorgNeeded(current *types.Header, extern *types.Header) (bool, error)
는 현재 캐노니컬 헤드(current)와 외부에서 제시된 새로운 헤드(extern)를 비교하여 체인 재구성(reorg)이 필요한지 여부를 판단.
extern 헤더가 현재 체인의 연장선에 있는 경우 단순 연장, extern이 다른 분기를 나타낼 경우 true
를 리턴. (재구성 해야됨을 알림)
extern 헤더가 DB에 없는 등 맛이 간 경우 에러를 리턴.
에러가 나면 Geth가 컨센서스 레이어에 동기화 필요(SYNCING 상태)를 응답.
Geth의 forkchoice 처리 로직은 이러한 ReorgNeeded
결과를 토대로, 필요시 체인 재구성을 수행하고 새로운 헤더를 캐노니컬 헤드로 승격시킴.
Geth 엔진 API에서 컨센서스 클라이언트가 전달한 포크 선택 상태를 수신하면
- 해당 헤드 블록 해시가 가리키는 블록을 자체 DB에서 조회
- 블록이 이미 삽입되어 있다면(예: 이전에
engine_newPayload
를 통해 검증/저장 완료), 곧바로ForkChoice
를 활용해 현재 헤드와 비교한 뒤 체인 헤드 포인터를 갱신 - 반면 블록 정보가 없다면 컨센서스 쪽에 동기화 필요(SYNCING 상태)를 보고하고, 필요한 페이로드들을 받아와 블록을 저장한 후 헤드를 갱신
참고: forkchoice 업데이트 처리 과정에서 안전 헤드(safe block)와 최종화된 블록(finalized block) 정보도 함께 제공받음
- Geth는 PoS의 포크 선택 알고리즘 자체(LMD-GHOST)는 구현하지 않지만 > Engine API를 통해 전달된 최종 포크 선택 결과를 반영하여 현재 체인 헤드를 전환하고
- 필요 시 새 블록을 빌드하거나 동기화함
ForkChoice 내용은 이거저거 찾아서 썼는데 시발 코드에서 없어졌다
3. 체인 재구성 (Reorg) 처리
체인 재구성(reorganization)이란 현재 선택된 캐노니컬 체인을 다른 분기로 변경하는 과정.
앞서 언급한 ForkChoice
모듈과 기존의 체인관리 코드를 활용하여 reorg를 처리함.
core.BlockChain
구조체는 블록을 DB에 저장하고 체인 상태를 관리.
SetHead
/SetFinalized
등의 함수와 reorg 시 분기 정리 로직이 구현됨.
예를 들어 BlockChain.SetFinalized(header)
는 주어진 헤더를 최종화 블록으로 설정하면서, 해당 블록보다 이전의 모든 분기 데이터를 정리함.
마찬가지로 BlockChain.SetSafe(header)
는 안전 블록(safe head)를 설정하여 체인 재구성 한계를 표시함.
Engine API 처리 루틴에서, 컨센서스 클라이언트가 전달한 finalized 블록 및 safe 블록 해시를 실제 헤더로 찾아 설정하는데 사용됨.
reorg 발생 시
- 공통 조상 블록(common ancestor)을 찾음
- 기존 체인의 해당 조상 이후 블록들을 캐노니컬 체인에서 제거
- 신규 체인의 블록들을 캐노니컬 체인으로 편입
- ChainHeadEvent와 ChainSideEvent 등을 발생시켜 상위 모듈에 체인 전환을 알림
- 트랜잭션 풀 등 부가 구성요소를 업데이트
ForkChoice.ReorgNeeded(...)
가true
를 반환하면, Geth는 일반적으로 새로운 헤더를 현재 헤드로 만들기 위해BlockChain
내부에서 재구성
최종화된 블록 이전으로의 reorg가 불가능하도록 보장되므로, Geth는 reorg 시 항상 최종화 지점 이후의 블록들만 대체함.
currentFinalBlock
과 currentSafeBlock
포인터를 관리하면서, reorg하려는 새로운 헤드가 이들 제약을 어기는지 검사.
만약 컨센서스 레이어로부터 전달된 새로운 헤드가 currentFinalBlock
이전의 분기를 가리킨다면 오류로 처리 (컨센서스가 똑바로 만들어져있다면 이런거 구경도 못함).
아까 ForkChoice 날린 PR에서 reorgNeeded도 같이 날아갔다.
BlockChain.writeBlockAndSetHead() 에서 head 블록과 새 블록의 hash 또는 height 만 보고 바로 reorg 처리를 한다. 이것을 가능하게 하는건 무엇일까?
BlockChain.SetFinalized()
// SetFinalized는 블록체인의 최종화된 블록 헤더를 설정합니다.
// header가 nil이 아닌 경우, 현재 최종화된 블록을 업데이트하고
// 데이터베이스에 최종화된 블록 해시를 저장하며 관련 메트릭을 업데이트합니다.
// header가 nil인 경우, 최종화된 블록을 제거하고 빈 해시와 0 값으로 초기화합니다.
func (bc *BlockChain) SetFinalized(header *types.Header) {
bc.currentFinalBlock.Store(header)
if header != nil {
rawdb.WriteFinalizedBlockHash(bc.db, header.Hash())
headFinalizedBlockGauge.Update(int64(header.Number.Uint64()))
} else {
rawdb.WriteFinalizedBlockHash(bc.db, common.Hash{})
headFinalizedBlockGauge.Update(0)
}
}
BlockChain.SetSafe()
// SetSafe는 블록체인의 안전한 블록 헤더를 설정합니다.
// 안전블록으로 설정된 헤더는 체인의 재구성이 거의 불가능함을 의미합니다.
func (bc *BlockChain) SetSafe(header *types.Header) {
bc.currentSafeBlock.Store(header)
if header != nil {
headSafeBlockGauge.Update(int64(header.Number.Uint64()))
} else {
headSafeBlockGauge.Update(0)
}
}
BlockChain.reorg()
// reorg는 두 개의 블록(구 체인과 신 체인)을 받아서 블록들을 재구성하고,
// 새로운 정규 체인의 일부가 되도록 블록들을 삽입하며, 누락될 수 있는 트랜잭션들을 수집하고
// 그것들에 대한 이벤트를 게시합니다.
//
// 주의: 새로운 헤드 블록은 여기서 처리되지 않으므로, 호출자가 외부에서 처리해야 합니다.
func (bc *BlockChain) reorg(oldHead *types.Header, newHead *types.Header) error {
var (
newChain []*types.Header // 새로운 체인에 추가될 헤더들
oldChain []*types.Header // 구 체인에서 제거될 헤더들
commonBlock *types.Header // 공통 조상 블록
)
// 더 긴 체인을 짧은 체인과 같은 길이로 맞춤
// 먼저 두 체인을 같은 높이로 맞춰서 공통 조상을 찾기 쉽게 함
if oldHead.Number.Uint64() > newHead.Number.Uint64() {
// 구 체인이 더 길 때, 모든 트랜잭션과 로그를 삭제된 것으로 수집
for ; oldHead != nil && oldHead.Number.Uint64() != newHead.Number.Uint64(); oldHead = bc.GetHeader(oldHead.ParentHash, oldHead.Number.Uint64()-1) {
oldChain = append(oldChain, oldHead)
}
} else {
// 새 체인이 더 길 때, 모든 블록을 후속 삽입을 위해 보관
for ; newHead != nil && newHead.Number.Uint64() != oldHead.Number.Uint64(); newHead = bc.GetHeader(newHead.ParentHash, newHead.Number.Uint64()-1) {
newChain = append(newChain, newHead)
}
}
// 체인 유효성 검증 - 헤더가 존재하지 않으면 오류
if oldHead == nil {
return errInvalidOldChain
}
if newHead == nil {
return errInvalidNewChain
}
// 양쪽 체인이 같은 높이에 있으므로, 공통 조상을 찾을 때까지 양쪽을 모두 역추적
for {
// 공통 조상을 찾았으면 루프 종료
if oldHead.Hash() == newHead.Hash() {
commonBlock = oldHead
break
}
// 구 블록을 제거 대상에 추가하고 새 블록을 추가 대상에 보관
oldChain = append(oldChain, oldHead)
newChain = append(newChain, newHead)
// 양쪽 체인 모두 한 단계씩 뒤로 이동
oldHead = bc.GetHeader(oldHead.ParentHash, oldHead.Number.Uint64()-1)
if oldHead == nil {
return errInvalidOldChain
}
newHead = bc.GetHeader(newHead.ParentHash, newHead.Number.Uint64()-1)
if newHead == nil {
return errInvalidNewChain
}
}
// 사용자가 큰 재구성을 인지할 수 있도록 로그 출력
if len(oldChain) > 0 && len(newChain) > 0 {
logFn := log.Info
msg := "Chain reorg detected"
// 63개 이상의 블록이 재구성되면 경고 레벨로 로그 출력
if len(oldChain) > 63 {
msg = "Large chain reorg detected"
logFn = log.Warn
}
logFn(msg, "number", commonBlock.Number, "hash", commonBlock.Hash(),
"drop", len(oldChain), "dropfrom", oldChain[0].Hash(), "add", len(newChain), "addfrom", newChain[0].Hash())
// 재구성 관련 메트릭 업데이트
blockReorgAddMeter.Mark(int64(len(newChain)))
blockReorgDropMeter.Mark(int64(len(oldChain)))
blockReorgMeter.Mark(1)
} else if len(newChain) > 0 {
// 특별한 경우: 현재 헤드가 새 헤드의 조상이지만 연속되지 않는 경우 (주로 머지 이후 단계에서 발생)
log.Info("Extend chain", "add", len(newChain), "number", newChain[0].Number, "hash", newChain[0].Hash())
blockReorgAddMeter.Mark(int64(len(newChain)))
} else {
// len(newChain) == 0 && len(oldChain) > 0
// 정규 체인을 더 낮은 지점으로 되감기 (이는 일반적으로 발생하지 않아야 함)
log.Error("Impossible reorg, please file an issue", "oldnum", oldHead.Number, "oldhash", oldHead.Hash(), "oldblocks", len(oldChain), "newnum", newHead.Number, "newhash", newHead.Hash(), "newblocks", len(newChain))
}
// 변경 전에 트랜잭션 룩업 락 획득
// 이 단계는 txlookup이 Atomic하게 변경되어야 하고, 변경이 완료될 때까지 모든 후속 읽기가 차단되어야 하므로 필수임
bc.txLookupLock.Lock()
// 재구성 실행: 체인의 구 블록들을 제거하고 새 블록들을 추가 시작
var (
deletedTxs []common.Hash // 삭제된 트랜잭션 해시 목록
rebirthTxs []common.Hash // 재생성된 트랜잭션 해시 목록
deletedLogs []*types.Log // 삭제된 로그 목록
rebirthLogs []*types.Log // 재생성된 로그 목록
)
// API에서 삭제된 로그 방출은 순방향 순서를 사용하는데, 이는 잘못된 것이지만
// 레거시 이유로 유지됩니다.
//
// TODO(karalabe): 이것은 제거되어야 하지만, 방법을 모르겠으므로 일부 API를 더 이상 사용하지 않는 것으로 표시해야 할지도?
{
// 구 체인의 블록들을 역순으로 처리하여 삭제된 로그 수집
for i := len(oldChain) - 1; i >= 0; i-- {
block := bc.GetBlock(oldChain[i].Hash(), oldChain[i].Number.Uint64())
if block == nil {
return errInvalidOldChain // 데이터베이스 손상, 주로 이상한 패닉을 방지하기 위함
}
if logs := bc.collectLogs(block, true); len(logs) > 0 {
deletedLogs = append(deletedLogs, logs...)
}
// 메모리 사용량 제한을 위해 512개씩 배치로 처리
if len(deletedLogs) > 512 {
bc.rmLogsFeed.Send(RemovedLogsEvent{deletedLogs})
deletedLogs = nil
}
}
// 남은 삭제된 로그가 있으면 전송
if len(deletedLogs) > 0 {
bc.rmLogsFeed.Send(RemovedLogsEvent{deletedLogs})
}
}
// 구 블록들을 역순으로 실행 취소
for i := 0; i < len(oldChain); i++ {
// 삭제된 모든 트랜잭션 수집
block := bc.GetBlock(oldChain[i].Hash(), oldChain[i].Number.Uint64())
if block == nil {
return errInvalidOldChain // 데이터베이스 손상, 주로 이상한 패닉을 방지하기 위함
}
for _, tx := range block.Transactions() {
deletedTxs = append(deletedTxs, tx.Hash())
}
// 삭제된 로그를 수집하고 새로운 통합을 위해 방출
if logs := bc.collectLogs(block, true); len(logs) > 0 {
// 최신 것부터 먼저 되돌림을 방출하고, 그 다음 이전 것들
slices.Reverse(logs)
// TODO(karalabe): 역방향 방출 부분에 연결
}
}
// 새 블록들을 순방향 순서로 적용
for i := len(newChain) - 1; i >= 1; i-- {
// 포함된 모든 트랜잭션 수집
block := bc.GetBlock(newChain[i].Hash(), newChain[i].Number.Uint64())
if block == nil {
return errInvalidNewChain // 데이터베이스 손상, 주로 이상한 패닉을 방지하기 위함
}
for _, tx := range block.Transactions() {
rebirthTxs = append(rebirthTxs, tx.Hash())
}
// 삽입된 로그를 수집하고 방출
if logs := bc.collectLogs(block, false); len(logs) > 0 {
rebirthLogs = append(rebirthLogs, logs...)
}
// 메모리 사용량 제한을 위해 512개씩 배치로 처리
if len(rebirthLogs) > 512 {
bc.logsFeed.Send(rebirthLogs)
rebirthLogs = nil
}
// 헤드 블록 업데이트
bc.writeHeadBlock(block)
}
// 남은 재생성된 로그가 있으면 전송
if len(rebirthLogs) > 0 {
bc.logsFeed.Send(rebirthLogs)
}
// 불필요한 인덱스를 즉시 삭제 (비정규 트랜잭션 인덱스, 헤드 위의 정규 체인 인덱스 포함)
batch := bc.db.NewBatch()
// 삭제된 트랜잭션에서 재생성된 트랜잭션을 제외한 것들의 룩업 엔트리 삭제
for _, tx := range types.HashDifference(deletedTxs, rebirthTxs) {
rawdb.DeleteTxLookupEntry(batch, tx)
}
// 새로운 정규 체인의 일부가 아닌 모든 해시 마커 삭제
// reorg 함수는 새 체인 헤드를 처리하지 않으므로, 새 체인 헤드보다 크거나 같은 모든 해시 마커를 삭제해야 함
number := commonBlock.Number
if len(newChain) > 1 {
number = newChain[1].Number
}
// 공통 블록 이후의 모든 정규 해시 삭제
for i := number.Uint64() + 1; ; i++ {
hash := rawdb.ReadCanonicalHash(bc.db, i)
if hash == (common.Hash{}) {
break // 더 이상 정규 해시가 없으면 중단
}
rawdb.DeleteCanonicalHash(batch, i)
}
// 배치 쓰기 실행
if err := batch.Write(); err != nil {
log.Crit("Failed to delete useless indexes", "err", err)
}
// 오래된 txlookup 캐시를 지우기 위해 tx lookup 캐시 리셋
bc.txLookupCache.Purge()
// 변경 후 트랜잭션 룩업 락 해제
bc.txLookupLock.Unlock()
return nil
}
4. 에포크 및 슬롯 관리
슬롯과 에포크 개념은 컨센서스 클라이언트에서 관리.
Geth는 슬롯/에포크를 몰라도 쓸 수 있지만, 블록 타임스탬프, 최종화 시점, 안전 블록 등을 통해 간접적으로 그 개념을 반영.
각 블록의 타임스탬프가 Beacon 체인의 슬롯 시간(12초 간격)에 부합하는지를 검증.
Merge 이후 블록 난이도가 0으로 고정되면서 (PoW 난이도 폐지) 타임스탬프가 똑바로 올라가는지만 검증하면 되므로, Geth의 VerifyHeader
구현은 부모보다 시간이 같거나 과거인 블록을 거부하여 슬롯 질서를 강제.
“이 타임스탬프가 정확히 12초 단위로 증가해야 한다” 는 규칙은 실행 클라이언트에서 강제하지는 않음.
컨센서스 층이 슬롯 번호에 따라 타임스탬프를 지정해주므로 실행층은 단순히 증가하였는지만 검증하면 충분.
최종화(finalization) 개념을 통해 에포크 경계를 인식함.
Casper FFG에 의해 매 에포크마다 하나의 체크포인트 블록이 최종화될 수 있는데, 컨센서스 클라이언트는 이 정보를 엔진 API ForkchoiceUpdated
에 finalizedBlockHash
파라미터로 넘김.
ForkchoiceUpdated
실행 시 finalizedBlockHash
를 블록 DB에서 찾아 현재 최종화 블록으로 설정. (BlockChain.SetFinalized(header)
함수 실행)
이 최종화 블록을 디스크에 기록해 두고 (rawdb.WriteFinalizedBlock
), 재기동 시 불러옴.
컨센서스 클라이언트는 ForkchoiceUpdated
호출할 때 safeBlockHash
도 같이 넘기는데 이는 최종화보다 느슨하지만 경제적으로 뒤집히기 어렵다고 간주되는 헤드임.
BlockChain.SetSafe(header)
를 호출해 안전 블록으로 설정.
- Geth는 슬롯 자체를 직접 관리하진 않지만 블록의 시간/높이 관리를 통해 슬록 흐름을 따름
- 에포크 개념은 최종화 블록 처리를 통해 반영
- 최종화 블록 갱신은 곧 에포크 단위의 체크포인트 확정과 같음
- Geth가 최종화된 데이터는 캐시 등 을 정리함
Beacon.VerifyHeader()
// VerifyHeader는 헤더가 이더리움 합의 엔진의 합의 규칙에 부합하는지 확인합니다.
// 이 함수는 PoW/PoA에서 PoS로의 전환을 올바르게 처리하기 위한 검증 로직을 포함합니다.
func (beacon *Beacon) VerifyHeader(chain consensus.ChainHeaderReader, header *types.Header) error {
// 라이브 머지 전환 중에는 합의 엔진이 터미널 총 난이도를 사용하여
// PoW(PoA)가 PoS로 전환되는 시점을 감지했습니다. 하지만 총 난이도 값을
// 유지하려면 제네시스부터 모든 블록을 적용하여 TD를 구축해야 합니다.
// 동기화 중에 체인의 끝부분이 이미 가지치기된 경우에는 이것이 불가능해집니다.
//
// Merge 전 블록과 Merge 후 블록을 구분하는 데 사용할 수 있는 한 가지 휴리스틱은
// 각각의 *난이도*가 >0인지 ==0인지 여부입니다. 물론 이것은 과거 체인이
// 올바른 TTD에서 진정으로 전환되었음을 더 이상 증명할 수 없다는 것을 의미하지만,
// 오래된 시점이 오래 전에 확정되었다고 간주한다면, 합의 클라이언트가
// 매우 오래된 히스토리를 다시 쓰려는 시도는 없어야 합니다.
//
// 아마도 필요하지 않지만 이 검증을 더욱 엄격하게 만들기 위해 추가할 수 있는
// 한 가지는 ==0이 >0으로 이어지는 것을 금지함으로써 체인이 >0에서 ==0 TD로
// 단 한 번만 전환할 수 있도록 강제하는 것입니다.
// 머지 후에서 머지 전으로 되돌리지 않는지 확인합니다.
// 이는 블록체인의 일관성을 보장하기 위한 중요한 검증입니다.
parent := chain.GetHeader(header.ParentHash, header.Number.Uint64()-1)
if parent == nil {
return consensus.ErrUnknownAncestor
}
// 부모 블록이 PoS(난이도 0)이고 현재 헤더가 PoW(난이도 > 0)인 경우
// 이는 유효하지 않은 전환이므로 에러를 반환합니다.
if parent.Difficulty.Sign() == 0 && header.Difficulty.Sign() > 0 {
return consensus.ErrInvalidTerminalBlock
}
// 난이도가 >0인 경우 머지 전 규칙으로 검증하고,
// 난이도가 ==0인 경우 머지 후 규칙으로 검증합니다.
if header.Difficulty.Sign() > 0 {
// PoW/PoA 블록: 기존 eth1 엔진의 검증 로직 사용
return beacon.ethone.VerifyHeader(chain, header)
}
// PoS 블록: 비콘 체인의 검증 로직 사용
return beacon.verifyHeader(chain, header, parent)
}
5. RANDAO 관련 로직
RANDAO는 이더리움 PoS에서 난수를 축적하는 방식으로, 블록 헤더에 포함되는 값을 통해 구현.
Merge 이후 이더리움 실행 블록 헤더의 mixHash
필드(PoW 때는 채굴 시 사용되던 필드)가 Beacon 체인으로부터 전달된 난수 (PrevRandao)로 채워지도록 재활용.
블록 난이도(difficulty) 필드는 항상 0으로 설정되어 더 이상 사용되지 않음.
Geth에서 Randao 값의 처리는 블록 생성 또는 검증 시 이루어진다.
컨센서스 클라이언트가 engine_preparePayload
(이거 쓴다는데 코드에서 못찾음)/engine_forkchoiceUpdated
호출을 통해 새로운 블록 제작을 요청할 때, 파라미터로 prevRandao
값이 포함됨.
이 값을 새로운 블록 헤더의 MixDigest
필드에 채워 넣고, 블록이 최종 생성될 때 (FinalizeAndAssemble
단계), EVM의 블록 컨텍스트에 포함되어 OPCODE 연산의 입력으로 사용된다.
블록 검증 측면에서, BeaconConsensus.VerifyHeader
구현은 PoS 블록의 경우 난이도 필드가 0인지, mixHash 필드가 임의의 32바이트 값으로 존재하는지 등을 확인.
Geth가 이 mixHash(prevRandao) 값의 진위를 독립적으로 검증할 수는 없다.
Randao 값은 Beacon 체인 상태에 따른 것이므로, 컨센서스 클라이언트가 올바른 서명 검증을 통해 보증하며, Geth는 해당 값을 신뢰하여 저장만 함.
따라서 Geth는 난수 값의 존재 여부 및 형식만 체크하고 (Header.MixDigest
가 nil이 아닌지 등) 블록 해시에 포함시킬 뿐, 추가 검증은 수행하지 않는다.
- 블록 헤더 구조: PoS 모드에서
Header.MixDigest
필드를prevRandao
값 저장용으로 사용.- 블록 OPCODE: 0x44 (
DIFFICULTY
)를PREVRANDAO
로 간주하여 헤더의 해당 필드 값을 반환.- 블록 생성 시: 컨센서스 층이 제공한 난수를 헤더에 설정 (예: Geth JSON-RPC 엔진 API 구현에서 payloadAttributes.prevRandao를 헤더에 세팅).
- 블록 검증 시: 난이도=0, mixHash 필드 존재 여부 등의 체크로 충분 (자체 난수 검증은 컨센서스에 위임).
prevRandao는 어디있을까?
ForkchoiceUpdatedV3 가 호출될 때, engine_forkchoiceUpdated 가 호출될 때
func (api *ConsensusAPI) ForkchoiceUpdatedV3(update engine.ForkchoiceStateV1, params *engine.PayloadAttributes) (engine.ForkChoiceResponse, error) {
...
payload 에 Random 값이 Randao 값이다
type PayloadAttributes struct {
Timestamp uint64 `json:"timestamp" gencodec:"required"`
Random common.Hash `json:"prevRandao" gencodec:"required"`
SuggestedFeeRecipient common.Address `json:"suggestedFeeRecipient" gencodec:"required"`
Withdrawals []*types.Withdrawal `json:"withdrawals"`
BeaconRoot *common.Hash `json:"parentBeaconBlockRoot"`
}