6주 동안 노드 한 대의 내부만 봤습니다. 오늘은 노드끼리 주고받는 네트워크 메시지를 보고, 이어서 스펙 문서 한 장이 geth의 어느 파일을 어떻게 바꿨는지를 따라갑니다. p2p, eth/protocols/eth, 그리고 EIP-4844가 남긴 여섯 군데입니다.
기준 코드: go-ethereum ca1f2e4d3
오늘 다루는 구간: 노드와 노드 사이
개인키로 서명
접수와 대기실
옵코드 하나씩
StateDB→트라이
조립과 합의 경계
전파와 EIP-4844
오늘은 두 부로 나뉜다. A부는 tx 전파: 노드끼리 주고받는 네트워크 메시지를 본다. B부는 EIP-4844 구현: 스펙 문서 한 장이 geth의 어느 파일을 어떻게 바꿨는지 여섯 군데를 따라간다.
오늘 답할 질문: 내 노드 txpool에 들어온 tx 하나가 다른 노드 txpool로 어떻게 복사되는가.
tx가 퍼지려면 세 가지가 순서대로 필요하다
연결할 후보 노드의 IP 주소를 모은다. 아직 연결은 안 한다: p2p/discover
후보 중 일부와 TCP 연결을 맺고, 상대 신원과 체인을 확인한다: p2p/rlpx
연결된 상대에게 tx를 보낸다. 메시지 종류가 두 가지다: eth/protocols/eth
- 단계가 나뉘어 있는 이유: 아래 두 단계는 무슨 대화를 하든 상관없이 동작한다. 그래서 eth 말고 snap 같은 다른 프로토콜도 같은 연결 위에서 돈다.
이더리움에는 노드 목록을 나눠주는 중앙 서버가 없다. 세 단계 전부를 노드가 스스로 해야 한다.
찾기는 연결할 노드의 IP 주소를 모은다
type Findnode struct {
Target Pubkey
}
const MaxNeighbors = 12- 1Ping
살아 있나
- 2Pong
살아 있다
- 3Findnode
주소 목록 요청
- 4Neighbors
최대 12개 응답
- 5각각에 Ping
응답 오는지 확인
- 6목록에 저장
그리고 다시 Findnode
UDP로 노드 주소를 주고받아 후보 목록을 만든다. 여기서는 연결도, tx 전송도 하지 않는다.
Findnode는 아는 주소 전부가 아니라 Target 근처의 노드를 요청한다. 응답 한 통에 12개까지만 담기는데, 모두가 같은 요청을 하면 같은 노드에만 연결이 몰리기 때문이다.
- 부트노드란: 소스에 IP가 적혀 있는 시작점 노드다. 처음 켠 노드는 아는 주소가 0개라 여기서만 하드코딩으로 출발한다(
params/bootnodes.go, 메인넷 4개). - Target에는 보통 난수를 넣는다: 값을 바꿔가며 반복 요청해 네트워크의 여러 구역에서 주소를 모은다.
- 이 고리는 계속 돈다: 노드가 켜져 있는 동안 멈추지 않는다. 결과물은 IP 주소 목록이다.
모은 주소 전부와 연결하지는 않는다
16개씩 담는 칸이 17개
목록에서 후보를 꺼내 TCP 연결을 시도한다
이 50개하고만 tx를 주고받는다
// 내가 거는 연결의 상한
limit = srv.MaxPeers / defaultDialRatio
// 50 / 3 = 16찾기와 연결은 별개의 코드다. 찾기는 주소를 모으기만 하고, 다이얼러라는 별도 코드가 그중 일부를 골라 연결한다.
피어 50개가 전부 내가 건 연결은 아니다. 내가 거는 건 16개까지고, 나머지 34개 자리는 남이 나에게 연결해 오는 몫이다.
- 왜 절반 넘게 남의 몫으로 비워두나: 전부 내가 걸면 방화벽 뒤에 있어 연결을 못 거는 노드가 네트워크에 참여할 수 없다.
- 연결하지 않은 나머지 200여 개를 계속 들고 있는 이유: ① 피어는 계속 끊기므로 빈자리를 메운다 ② 다른 노드가 나에게 Findnode를 보내면 내 목록에서 골라 응답한다 ③ 후보가 많아야 느리거나 응답 없는 노드를 걸러내고 쓸 수 있다.
주소를 안다와 연결되어 있다는 다른 상태다.
연결은 상대의 신원과 체인을 확인한다
pkt := &StatusPacket68{
NetworkID: networkID, // 메인넷은 1
Genesis: genesis.Hash(),
ForkID: forkID,
}
if status.NetworkID != networkID { /* 연결 종료 */ }노드의 ID는 공개키다. 각자 자기 개인키와 상대 공개키로 계산하면 같은 값이 나오는 성질을 이용해, 그 값을 만들 수 있는지로 신원을 확인한다. 확인에 쓴 값으로 이후 통신을 암호화한다.
암호화 통로가 열린 뒤 체인 정보를 주고받는다. 네트워크 번호, 첫 블록 해시, 적용한 하드포크 이력 중 하나라도 다르면 연결을 끊는다.
- ForkID가 하는 일: 지금까지 적용한 하드포크의 요약과 다음 포크 블록 번호다. 지금은 같은 체인이어도 업그레이드가 밀린 노드는 포크 시점에 갈라지므로 미리 거른다.
- 다른 체인은: 그 앞의 NetworkID에서 이미 걸러진다.
- 이 검사를 통과한 상대만 피어가 된다: 아래부터는 이 피어들과 tx를 주고받는 이야기다.
전달은 중복이 몇 번이냐에 따라 유리한 방식이 갈린다
피어 n개가 같은 tx를 나에게 알릴 때 오가는 바이트를 두 방식으로 세어 보면 이렇다.
110 × n
tx 하나가 약 110바이트다.
32 × n + 32 + 110
알림 n개 + 내 요청 + 원문 한 벌.
110 vs 174: 원문 유리
220 vs 206: 비슷
550 vs 302: 해시 유리
5,500 vs 1,742: 해시 유리
tx는 사방에서 동시에 퍼진다. 여러 피어가 각자 다른 경로로 같은 tx를 받고, 각자 나에게 알리려 한다.
중복이 적으면 원문이, 많으면 해시가 유리한데 어느 쪽일지 모른다. 그래서 한 방식만 고를 수 없다.
- 실제 중복 횟수는 미리 알 수 없다: 피어마다 그 피어가 이미 아는 해시 장부를 최대 32,768개까지 들고 있어(
knownTxs) 한 번 주고받은 tx는 다시 보내지 않는다. 다만 이 장부는 내가 보낼 때만 거른다. 상대가 나에게 보내는 것은 못 막으므로, 중복이 몇 번일지는 보내는 시점에 알 수 없다.
보내는 쪽은 피어 49개 중 7개에만 원문을 보낸다
for _, tx := range txs {
switch {
case tx.Type() == types.BlobTxType: // 해시만
case tx.Size() > txMaxBroadcastSize: // 4096
default:
directSet = choice.choosePeers(peers, txSender)
}
}
n := int(math.Ceil(math.Sqrt(float64(len(bc.tmp)))))새 tx가 txpool에 들어오면 피어를 두 그룹으로 나눈다. √49 = 7개에는 원문(0x02)을, 나머지 42개에는 해시(0x08)를 보낸다.
7개에 원문을 미리 보내는 이유는 두 가지다. 받는 쪽이 왕복 없이 한 벌을 확보해 다음 노드로 바로 넘길 수 있고, 그렇게 퍼진 원본이 있어야 뒤따르는 해시 알림이 32바이트로 끝난다.
- 해시를 받은 42개도 결국 원문을 받는다: 그 tx를 모르면 나에게 요청해 오고, 그때 원문을 보낸다. 즉 42개 쪽은 조건부 전송이다.
- 7개를 고르는 계산에 tx 발신 계정 주소가 들어간다: 같은 계정의 tx는 항상 같은 피어로 간다.
- 발신 계정을 계산에 넣는 이유: 한 계정의 tx에는 0, 1, 2 순번(nonce)이 붙고 순서를 건너뛰어 실행되지 않는다. 5번과 6번이 다른 경로로 퍼져 6번만 먼저 도착하면 그 노드는 5번이 올 때까지 6번을 블록에 넣지 못한다.
원문이 해시의 절약을 만든다. 모두 해시로만 보내면 중복이 안 생겨 절약할 것도 없다.
받는 쪽은 해시 알림을 받으면 500ms 뒤에 요청한다
- 1해시 도착
0x08
- 2500ms 대기
txArriveTimeout
- 3피어 1개에 요청
0x09
- 4원문 도착
0x0a
- 5txpool에 추가
txpool.Add
// 2주차에 본 txpool 입구 함수를 그대로 호출한다
addTxs := func(txs []*types.Transaction) []error {
return h.txpool.Add(txs, false)
}그 사이 다른 피어가 원문을 보내줄 수 있다. 나도 누군가의 7개 안에 들어 있을 수 있고, 그러면 요청 자체가 불필요해진다.
알린 피어 중 하나에게만 보낸다. 50개 전부에 요청하면 해시 방식으로 아낀 대역폭이 도로 소모된다.
- 도착한 tx는 지갑이 직접 보낸 tx와 같은 함수로 들어간다: 같은 검증을 받는다. 단, RPC 입구에만 있는 이 노드 자체의 정책 검사는 거치지 않는다.
- 정책 검사가 다른 이유: 수수료 상한 같은 검사는 합의 규칙이 아니라 이 노드가 자기 사용자에게 거는 안전장치다. 다른 노드 사용자의 tx에까지 적용할 근거가 없다.
A부 정리: tx 한 개의 전파 경로 전체
- 1지갑
어느 노드에 tx를 제출한다
- 2그 노드
검증하고 txpool에 저장한다
- 3전파 시작
피어의 제곱근에게 원문(0x02)을, 나머지에게 해시(0x08)를 보낸다
- 4원문 받은 피어
같은 규칙으로 자기 피어의 제곱근에게 다시 보낸다
- 5해시 받은 피어
500ms 뒤 피어 하나에 요청(0x09)해 원문(0x0a)을 받는다
- 6전 노드의 txpool
몇 초 안에 같은 tx가 복사된다
- 3번이 가능하려면 앞의 두 단계가 끝나 있어야 한다: 주소를 모아뒀고(1단계), 그중 일부와 연결해 신원과 체인을 확인해뒀어야(2단계) 한다.
그 시점에 블록을 만드는 노드가 어디에 있든 거의 같은 tx 집합을 갖고 있다.
블록은 이 경로로 전파되지 않는다
func handleNewBlock(...) error {
return errors.New("block broadcasts disallowed")
}순서를 정하는 쪽이 전파도 맡는다. CL끼리의 별도 네트워크로 블록이 퍼지고, geth는 짝인 CL에게 블록을 받아 실행한다.
앞에서 본 tx 교환, 그리고 뒤처진 노드가 과거 블록과 상태를 받아가는 데이터 응답.
- 블록 전파 메시지를 받으면: 처리하지 않고 에러를 내며 연결을 끊는다.
- 실행 클라이언트(EL)와 합의 클라이언트(CL): 2022년 머지 이후 이더리움 노드는 프로그램 두 개가 짝을 이룬다. CL은 누가 다음 블록을 만들지 순서를 정하고, EL은 tx를 실행하고 상태를 보관한다. geth가 EL이고 6주 동안 읽은 코드가 이것이다.
머지는 노드를 두 프로그램으로 나눴고, 네트워크도 두 개로 나눴다.
B부: 롤업 데이터를 calldata에 싣던 방식의 한계
calldata는 tx에 붙여 보내는 데이터 영역이다. 원래는 컨트랙트 호출의 함수와 인자를 담는 자리이고, 임의 데이터도 넣을 수 있다. 바이트 수에 비례해 수수료를 낸다.
일반 tx와 같은 수수료 시장에서 경쟁한다: 다른 수요가 몰리면 롤업 비용도 함께 오른다. 그리고 체인에 들어간 데이터는 모든 노드가 영구 보관한다: 롤업 데이터는 이의 제기 기간만 필요한데도 그렇다.
- 롤업이란 (아비트럼, 베이스 등): 시퀀서 한 대가 tx를 밖에서 실행하고, 처리한 원본 데이터를 이더리움에 올려 공개한다. 이더리움이 그 결과를 검증해 주는 게 아니라, 누구든 원본을 받아 재실행해 시퀀서의 거짓말을 잡을 수 있게 하는 것이 목적이다.
- 이의 제기 기간이란: 시퀀서가 결과를 올린 뒤 누구든 반박할 수 있는 기간이다. 아비트럼과 베이스는 7일이다. 지나면 확정되어 다툴 일이 없으므로 원본도 그때까지만 필요하다. blob의 18일은 이 기간을 덮는 값이다.
해법은 별도 데이터 영역(blob)을 만드는 것이다.
128KB: 4096 × 32바이트
목표 6개 · 상한 9개: 도입 시점엔 3과 6
불가: 해시만 읽을 수 있다
약 18일 뒤 삭제: 상태에 저장되지 않는다
EIP-4844 ① tx 본체와 데이터를 분리한다
type BlobTx struct {
ChainID, Nonce, GasFeeCap, ...
BlobFeeCap *uint256.Int
BlobHashes []common.Hash // blob 해시 목록
Sidecar *BlobTxSidecar `rlp:"-"`
}
type BlobTxSidecar struct {
Version byte
Blobs, Commitments, Proofs
}가스, nonce, 그리고 blob의 해시 목록. 서명과 tx 해시는 이 범위만 포함한다.
blob 원본과 검증용 값들. 서명 범위 밖이라 떼어내도 tx는 유효하다.
- `rlp:"-"` 태그가 핵심이다: 이 필드는 직렬화에서 제외하라는 표시이고, 그래서 서명 대상에도 tx 해시 계산에도 들어가지 않는다.
- BlobHashes는: blob의 요약값을 해시해 만들고 첫 바이트를 버전 번호로 덮은 값이다.
- 직렬화란: 구조체를 전송하거나 저장할 수 있는 바이트 배열로 바꾸는 것이다. 이더리움은 RLP라는 자체 형식을 쓴다.
본체와 데이터가 분리 가능하다는 이 설계가 뒤에 나오는 처리 방식의 전제다.
EIP-4844 ② blob에 독립된 가스 시장이 생긴다
// 목표 초과분의 누적치를 계산한다
excessBlobGas = parentExcess + parentUsed
targetGas = 목표 6개 분량
return excessBlobGas - targetGas
// 가격 = 이 누적치의 지수함수블록이 목표보다 붐비면 가격이 오르고 한산하면 내린다. 일반 tx와 calldata가 여기서 경쟁한다.
같은 방식으로 따로 계산된다. 일반 수수료가 올라도 blob 수요가 적으면 blob 값은 낮다.
- 누적치가 가격을 만든다: 목표보다 많이 쓰는 블록이 이어지면 누적치가 커지고 가격이 빠르게 오른다. 한산해지면 줄어들며 내려간다.
- blob 비용은 선불이고 환불이 없다: 실행 가스는 덜 쓰면 돌려받지만 blob은 실은 만큼 낸다.
- 하드포크가 필요했던 이유: 이 계산을 하려면 블록 헤더에 필드 두 개(
ExcessBlobGas,BlobGasUsed)가 추가돼야 한다. 헤더 변경은 모든 노드가 동의해야 하므로 하드포크로만 가능하다.
시장을 나눈 것이 EIP-4844의 목적이다. 롤업 비용이 일반 수요와 분리된다.
EIP-4844 ③ 컨트랙트는 blob 내용 대신 해시만 읽는다
func enable4844(jt *JumpTable) {
jt[BLOBHASH] = &operation{ // 0x49 칸
execute: opBlobHash,
constantGas: GasFastestStep,
}
}칸쿤 하드포크용 배열을 만들 때 이 함수가 호출돼 0x49 칸을 채운다. opBlobHash는 스택에서 인덱스를 꺼내 그 tx의 BlobHashes에서 해당 해시를 반환한다.
점프 테이블은 하드포크마다 한 벌씩 있어서, 새 명령 추가는 새 배열의 빈 칸을 채우는 작업이 된다. 3주차 커스텀 옵코드 과제와 같은 구조다.
- blob 내용 검증 창구는 프리컴파일 0x0a다: 프리컴파일은 옵코드로 만들기에 무거운 연산을 특정 주소에 미리 구현해 둔 것이다. 그 주소를 호출하면 EVM이 해석하지 않고 Go 코드가 처리한다. 0x0a는 blob을 열지 않고도 특정 지점의 값이 맞는지 증명으로 확인해 주며, 롤업의 사기 증명이 사용한다.
EIP 하나가 EVM에 추가한 것은 옵코드 한 칸과 프리컴파일 한 자리다.
EIP-4844 ④ blob tx의 처리 경로 전체
- 1txpool
타입 0x03 전용 풀로 들어간다. blob 하나가 128KB이고 한 tx에 여러 개 붙어 메모리가 아니라 디스크에 저장한다
- 2전파
원문을 아무 피어에도 보내지 않고 해시만 알린다. 원문을 보내온 피어는 연결을 끊는다
- 3블록 조립
WithoutBlobTxSidecar로 사이드카를 분리한다. 블록에는 해시가 든 본체만 들어간다
- 4사이드카
분리된 사이드카는 CL로 넘어가고, CL이 자기 네트워크로 전파한다
- 5블록 검증
블록 안의 blob tx에 사이드카가 붙어 있으면 유효하지 않은 블록으로 판정한다
- 6삭제
약 18일 뒤 CL이 삭제한다. 블록 해시와 상태에 blob 내용이 없어 검증에 영향이 없다
- A부에서 남겨둔 질문의 답이 2번이다: blob tx 원문을 아무에게도 보내지 않는 이유는 사이드카가 크기 때문이다.
tx 타입, txpool, 전파, 블록 조립, 검증, EVM과 수수료. EIP 한 장이 여섯 군데를 바꿨다.
6주 커리큘럼 전체 경로
서명
2주
오늘
5주
3주
4주
3 · 5주
오늘
원문 전송과 해시 알림. 피어의 제곱근에만 원문을 보내고 나머지에는 해시를 보낸다. 크기가 큰 tx는 원문을 보내지 않는다
tx는 실행 클라이언트 네트워크로, 블록과 blob은 합의 클라이언트 네트워크로 전파된다
EIP-4844 한 장이 바꾼 여섯 군데를 6주 동안 이미 다 지나왔다
6주 전에 던진 질문에 이제 답할 수 있다: 내가 보낸 tx는 누구를 거쳐 모두의 진실이 되는가. 입구부터 저장까지 그 경로의 모든 단계를 코드로 짚을 수 있게 됐습니다. 고생하셨습니다.