티스토리 뷰
본 게시글에서는 저서 '밑바닥부터 시작하는 비트코인'의 Python으로 작성된 예제 코드를 Go로 컨버팅 하여 작성하였습니다.
📺시리즈
2023.08.25 - [블록체인/Bitcoin] - 밑바닥부터 시작하는 비트코인 - 1장 유한체
2023.08.27 - [블록체인/Bitcoin] - 밑바닥부터 시작하는 비트코인 - 2장 타원곡선
2023.08.30 - [블록체인/Bitcoin] - 밑바닥부터 시작하는 비트코인 - 3장 타원곡선 암호
2023.09.02 - [블록체인/Bitcoin] - 밑바닥부터 시작하는 비트코인 - 4장 직렬화
2023.09.05 - [블록체인/Bitcoin] - 밑바닥부터 시작하는 비트코인 - 5장 트랜잭션
2023.09.11 - [블록체인/Bitcoin] - 밑바닥부터 시작하는 비트코인 - 6장 스크립트
2023.09.16 - [블록체인/Bitcoin] - 밑바닥부터 시작하는 비트코인 - 7장 트랜잭션 검증과 생성
2023.09.20 - [블록체인/Bitcoin] - 밑바닥부터 시작하는 비트코인 - 8장 p2sh 스크립트
2023.09.21 - [블록체인/Bitcoin] - 밑바닥부터 시작하는 비트코인 - 9장 블록
🐲 전체 코드
🥅 학습 목표
1. 비트코인 네트워크에서 P2P 통신을 위해 사용하는 메시지의 형식을 알아봅니다.
2. 비트코인 네트워크 프로토콜에 따라 원격 노드와 P2P 통신을 수립하는 핸드셰이크 과정을 알아봅니다.
3. 비트코인 네트워크 프로토콜에 따라 블록 헤더를 요청, 수신, 검증하는 과정을 살펴봅니다.
✉️ 1. 네트워크 메시지
1.1 네트워크 메시지 정의
모든 네트워크 메시지는 다음과 같은 형식입니다.
네트워크 매직 | 4 bytes |
명령어 | 12 bytes, human-readable |
페이로드 길이 | 4bytes, little-endian |
페이로드 체크썸 | 4bytes |
페이로드 | 가변 길이, 크기 제한은 있음 |
처음 4바이트는 항상 네트워크 매직(network magic) 바이트로 시작합니다. 매직 바이트는 비동기 통신에서 연결이 끊어졌을 때 재접속을 위해 신호의 시작점을 알리는 용도로 사용됩니다. 또한 네트워크를 식별하는 데에도 사용하여 메인넷과 테스트넷과 같이 서로 다른 네트워크의 메시지를 거부할 수 있습니다.
이어지는 12바이트는 페이로드에 무엇이 담겨있는지 알려주는 명령어입니다. 명령어는 사람이 읽기 쉽게 'version', 'getheaders'와 같이 영어로 되어 있고 12바이트를 채워 넣기 위해 명령어 뒤에 비어있는 공간을 0x00으로 채워 넣습니다.
다음 4바이트는 리틀 엔디언으로 읽는 페이로드의 길이입니다. 페이로드는 가변 길이를 가지므로 길이를 따로 명시해 줄 필요가 있습니다. 참고로 페이로드의 크기 32MB 이하로 제한됩니다.
다음 4바이트는 페이로드 체크썸입니다. 페이로드의 hash256 해시값의 첫 4바이트를 사용합니다.
네트워크 메시지를 처리하는 NetworkEnvelope 구조체를 다음과 같이 정의하였습니다.
package network
import (
"chapter10/utils"
"encoding/hex"
"fmt"
)
type NetworkEnvelope struct {
Magic []byte // 4 bytes
Command Command // 12 bytes
Payload []byte // variable
}
func NewEnvelope(command, payload []byte, network ...NetworkType) (*NetworkEnvelope, error) {
ne := &NetworkEnvelope{
Magic: NetworkMagic,
Command: command,
Payload: payload,
}
if len(network) > 0 {
switch network[0] {
case TestNet:
ne.Magic = TestNetworkMagic
case SimNet:
ne.Magic = SimNetMagic
}
}
return ne, nil
}
func (ne NetworkEnvelope) String() string {
return fmt.Sprintf("%s %s", ne.Command, hex.EncodeToString(ne.Payload))
}
Command 별칭 타입을 정의하여 명령어를 직렬화하고 파싱 하는 부분을 담당하게 하였습니다.
type Command []byte
var (
VersionCommand Command = []byte("version")
VerAckCommand Command = []byte("verack")
PingCommand Command = []byte("ping")
PongCommand Command = []byte("pong")
GetHeadersCommand Command = []byte("getheaders")
HeadersCommand Command = []byte("headers")
)
func (c Command) String() string {
return string(bytes.Trim(c, "\x00"))
}
func (c Command) Serialize() []byte {
b := make([]byte, 12)
copy(b, c)
return b
}
func (c Command) Compare(other Command) bool {
return bytes.Equal(c, other)
}
func ParseCommand(b []byte) Command {
return Command(bytes.Trim(b, "\x00"))
}
네트워크 메시지의 직렬화와 파싱은 다음과 같이 이루어집니다.
// 직렬화
func (ne NetworkEnvelope) Serialize() ([]byte, error) {
result := ne.Magic[:]
result = append(result, ne.Command.Serialize()...)
result = append(result, utils.IntToLittleEndian(len(ne.Payload), 4)...)
result = append(result, utils.Hash256(ne.Payload)[:4]...)
result = append(result, ne.Payload...)
return result, nil
}
// 파싱
func ParseNetworkEnvelope(b []byte) (*NetworkEnvelope, error) {
buf := bytes.NewBuffer(b)
magic := buf.Next(4)
if !IsNetworkMagicValid(magic) {
return nil, ErrInvalidNetworkMagic
}
command := ParseCommand(buf.Next(12))
payloadLength := utils.LittleEndianToInt(buf.Next(4))
payloadChecksum := buf.Next(4)
payload := buf.Next(payloadLength)
if !bytes.Equal(payloadChecksum, utils.Hash256(payload)[:4]) {
return nil, ErrInvalidPayload
}
return &NetworkEnvelope{
Magic: magic,
Command: command,
Payload: payload,
}, nil
}
1.2 페이로드
각 커맨드는 서로 다른 페이로드로 구성되어 있으며 페이로드마다 파싱하고 직렬화하는 방법이 다릅니다. 다음은 핸드셰이크의 시작을 알리는 'version' 커맨드의 페이로드 형식입니다.
프로토콜 버전 | 4 bytes, little-endian, 70015 |
네트워크 서비스 | 8 bytes, little-endian |
타임스탬프 | 8 bytes, little-endian |
수신자의 네트워크 서비스 | 8 bytes, little-endian |
수신자의 IP 주소 | 16 bytes |
수신자의 포트 번호 | 2 bytes |
송신자의 네트워크 서비스 | 8 bytes, little-endian |
송신자의 IP 주소 | 16 bytes |
송신자의 포트 번호 | 2 bytes |
논스 | 8 bytes |
사용자 에이전트 | 가변 길이 |
높이 | 4 bytes |
릴레이 | 1 byte |
첫 번째 필드는 네트워크 프로토콜 버전 정보를 담습니다. 프로토콜 버전에 따라 사용할 수 있는 명령어가 제한됩니다. 네트워크 서비스는 연결된 노드 사이에 사용 가능한 서비스 정보를 담고 있습니다. 타임스탬프는 8바이트 리틀 엔디언으로 읽습니다.
IP 주소는 IPv4, IPv6 또는 Tor의 .onion 주소를 IPv6로 변환한 주소 형식인 OnionCat으로 표현합니다. 만약 IP 주소가 IPv4 형식인 경우, 첫 12바이트를 00000000000000000000ffff로 채워 넣어 IPv6 형식에 맞춰줍니다. 포트 번호는 2바이트 빅 엔디언 정수입니다. 메인넷의 기본 포트 번호는 8333, 테스트넷은 18333으로 쓰입니다.
논스는 노드 간의 연결에서 자기 자신과의 무의미한 연결을 식별하기 위해 사용되며, 사용자 에이전트는 실행 중인 소프트웨어에 관한 정보를 담고 있습니다. 높이는 메시지를 송신하는 노드가 가장 최근에 동기화한 블록 높이를 알려주며, 릴레이는 블룸 필터(12장)와 관련이 있습니다.
버전 메시지의 페이로드를 처리하는 VersionMessage 구조체를 다음과 같이 정의했습니다.
type VersionMessage struct {
Version int32 // 4 bytes
Services int64 // 8 bytes
Timestamp int64 // 8 bytes
ReceiverServices int64 // 8 bytes
ReceiverIP []byte // 16 bytes (IPv4)
ReceiverPort int16 // 2 bytes
SenderServices int64 // 8 bytes
SenderIP []byte // 16 bytes (IPv4)
SenderPort int16 // 2 bytes
Nonce []byte // 8 bytes
UserAgent []byte // variable
LastestBlock int32 // 4 bytes
Relay bool // 1 byte
}
func DefaultVersionMessage(network ...NetworkType) *VersionMessage {
msg := &VersionMessage{
Version: 70015,
Services: 0,
Timestamp: time.Now().Unix(),
ReceiverServices: 0,
ReceiverIP: []byte{0x00, 0x00, 0x00, 0x00},
ReceiverPort: 8333,
SenderServices: 0,
SenderIP: []byte{0x00, 0x00, 0x00, 0x00},
SenderPort: 8333,
Nonce: []byte{0, 0, 0, 0, 0, 0, 0, 0},
UserAgent: []byte("/Satoshi:22.0.0/"),
LastestBlock: 0,
Relay: false,
}
if len(network) > 0 {
switch network[0] {
case TestNet:
msg.SenderIP = []byte{0x7F, 0x00, 0x00, 0x01}
msg.ReceiverPort = 18333
msg.SenderPort = 18333
case SimNet:
msg.ReceiverIP = []byte{0x7F, 0x00, 0x00, 0x01}
msg.SenderIP = []byte{0x7F, 0x00, 0x00, 0x01}
msg.ReceiverPort = 18555
msg.SenderPort = 18555
}
}
return msg
}
func NewVersionMessage(version int32, services int64, timestamp time.Time,
receiverServices int64, receiverIP []byte, receiverPort int16,
senderServices int64, senderIP []byte, senderPort int16,
nonce []byte, userAgent []byte, lastBlock int32, relay bool) (*VersionMessage, error) {
msg := DefaultVersionMessage()
if version != 0 {
msg.Version = version
}
if services != 0 {
msg.Services = services
}
if !timestamp.IsZero() {
msg.Timestamp = timestamp.Unix()
}
if receiverServices != 0 {
msg.ReceiverServices = receiverServices
}
if receiverIP != nil {
msg.ReceiverIP = receiverIP
}
if receiverPort != 0 {
msg.ReceiverPort = receiverPort
}
if senderServices != 0 {
msg.SenderServices = senderServices
}
if senderIP != nil {
msg.SenderIP = senderIP
}
if senderPort != 0 {
msg.SenderPort = senderPort
}
if nonce == nil {
temp, err := rand.Int(rand.Reader, big.NewInt(0).Exp(big.NewInt(2), big.NewInt(64), nil))
if err != nil {
return nil, err
}
msg.Nonce = utils.IntToLittleEndian(int(temp.Int64()), 8)
}
if userAgent != nil {
msg.UserAgent = userAgent
}
if lastBlock != 0 {
msg.LastestBlock = lastBlock
}
msg.Relay = relay
return msg, nil
}
func (vm VersionMessage) Command() Command {
return VersionCommand
}
func (vm VersionMessage) Serialize() ([]byte, error) {
result := utils.IntToLittleEndian(int(vm.Version), 4)
result = append(result, utils.IntToLittleEndian(int(vm.Services), 8)...)
result = append(result, utils.IntToLittleEndian(int(vm.Timestamp), 8)...)
result = append(result, utils.IntToLittleEndian(int(vm.ReceiverServices), 8)...)
result = append(result, append(append(bytes.Repeat([]byte{0x00}, 10), []byte{0xff, 0xff}...), vm.ReceiverIP...)...)
result = append(result, utils.IntToBytes(int(vm.ReceiverPort), 2)...)
result = append(result, utils.IntToLittleEndian(int(vm.SenderServices), 8)...)
result = append(result, append(append(bytes.Repeat([]byte{0x00}, 10), []byte{0xff, 0xff}...), vm.SenderIP...)...)
result = append(result, utils.IntToBytes(int(vm.SenderPort), 2)...)
result = append(result, vm.Nonce...)
result = append(result, utils.EncodeVarint(len(vm.UserAgent))...)
result = append(result, vm.UserAgent...)
result = append(result, utils.IntToLittleEndian(int(vm.LastestBlock), 4)...)
if vm.Relay {
result = append(result, 0x01)
} else {
result = append(result, 0x00)
}
return result, nil
}
🤝 2. 네트워크 핸드셰이크
2.1 핸드셰이크 수립 과정
- A는 B와 연결하기 위해 version 메시지를 전송합니다.
- B는 version 메시지를 받고 verack 메시지로 응답합니다. 그리고 바로 자신의 version 메시지를 전송합니다.
- A는 B가 보낸 verack, version 메시지를 차례로 받고 verack 메시지로 응답합니다.
- B가 verack 메시지를 받으면 두 노드 간의 통신 링크가 수립됩니다.
2.2 네트워크 접속
비트코인 네트워크는 기본적으로 비동기 특성을 가지고 있지만, 본 예제 코드에서는 간단한 연결 테스트를 위해서 동기식으로 코드를 작성하였습니다.
네트워크에 접속하기 위한 SimpleNode 구조체는 다음과 같습니다. net.Dial 함수를 사용하여 P2P 네트워크에 접속하고 SimpleNode 구조체에 연결 정보를 저장합니다.
type SimpleNode struct {
Host string
Port int
Network NetworkType
Logging bool
conn net.Conn
serverCloseChan chan struct{}
}
func NewSimpleNode(host string, port int, network NetworkType, logging bool) (*SimpleNode, error) {
if port == 0 {
switch network {
case MainNet:
port = DefaultMainNetPort
case TestNet:
port = DefaultTestNetPort
case SimNet:
port = DefaultSimNetPort
default:
return nil, ErrInvalidNetwork
}
}
conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", host, port))
if err != nil {
return nil, err
}
node := &SimpleNode{
Host: host,
Port: port,
Network: network,
Logging: logging,
conn: conn,
serverCloseChan: make(chan struct{}),
}
return node, nil
}
핸드셰이크 메서드는 다음과 같습니다.
func (sn *SimpleNode) HandShake() (<-chan bool, error) {
msg := DefaultVersionMessage()
err := sn.Send(msg, sn.Network)
if err != nil {
return nil, err
}
respChan := make(chan bool)
envelopes, errors := sn.WaitFor([]Command{VersionCommand, VerAckCommand})
go func() {
defer close(respChan)
for {
select {
case envelope := <-envelopes:
if envelope == nil {
continue
}
if envelope.Command.Compare(VerAckCommand) {
if sn.Logging {
log.Printf("Recv: %s\n", envelope.Command)
}
continue
}
if envelope.Command.Compare(VersionCommand) {
if sn.Logging {
log.Printf("Recv: %s\n", envelope.Command)
}
ack := NewVerAckMessage()
err = sn.Send(ack, sn.Network)
if err != nil {
if sn.Logging {
log.Printf("Error: %s\n", err)
}
return
}
if sn.Logging {
log.Println("Successfully established connection")
}
respChan <- true
return
}
case err := <-errors:
if err != nil {
if sn.Logging {
log.Printf("Error: %s\n", err)
}
return
}
}
}
}()
return respChan, nil
}
2.3 핸드셰이크 실행
핸드셰이크를 직접 실행해 보기 위해서는 테스트넷 주소가 필요합니다. 안타깝게도 책에서 저자분이 제공해 주신 테스트넷 주소는 현재 비활성화되어 있어 사용할 수 없습니다. 그래서 테스트넷 주소를 어디선가 찾아야 하는데, 저도 이 방법을 고민하다가 해답을 찾지 못해서 이 챕터를 마무리 짓지 못하고 오랫동안 방치해 두었습니다. 그러다 최근에서야 테스트넷 노드를 직접 실행해 보면 되지 않을까? 하는 생각에 도달하게 되어 go 언어의 비트코인 노드 구현체인 btcd를 사용해서 테스트넷 노드를 실행해 보았습니다.
테스트넷 노드를 직접 돌린다기보다는 처음에 셋업 하는 과정에서 피어 노드의 주소를 이런 식으로 콘솔에 출력해 주기 때문에, 풀 노드를 돌리지 않아도 이 주소를 가져와서 사용할 수 있었습니다.
핸드셰이크 실행 코드 및 결과는 다음과 같습니다.
func main() {
node, err := network.NewSimpleNode("71.13.92.62", 18333, network.TestNet, true)
if err != nil {
panic(err)
}
defer node.Close()
fmt.Println("Connected to", node.Host, "on port", node.Port)
resp, err := node.HandShake()
if err != nil {
panic(err)
}
if ok := <-resp; !ok {
panic("Handshake failed")
}
fmt.Println("Handshake successful")
}
📦 3. 블록 헤더
3.1 블록 헤더 요청
블록 헤더는 노드가 처음으로 네트워크에 연결되고 입수해야 하는 가장 중요한 데이터입니다. 풀 노드는 블록 헤더를 우선적으로 받고 난 뒤에 전체 블록 데이터를 내려받으며, 라이트 노드는 블록 헤더를 내려받으면 각 블록의 작업증명을 검증할 수 있습니다.
블록 헤더 요청은 'getheaders' 명령어를 사용하며, 메시지의 페이로드 형식은 다음과 같습니다.
프로토콜 버전 | 4 bytes, little-endian |
블록 헤더 그룹의 개수 | varint |
시작 블록 헤더 | 80 bytes, little-endian |
마침 블록 헤더 | 80 bytes, little-endian |
getheaders 메시지는 버전 정보로 시작하여 블록 헤더 그룹의 개수(시작 블록 헤더의 개수를 여러 개 지정 가능), 시작 블록 헤더 그리고 마침 블록 헤더로 구성되어 있습니다. 시작 블록 헤더는 반드시 지정해 주어야 하며, 마침 블록 헤더가 000...000인 경우에는 블록 헤더를 최대한 많이 보내달라는 요청을 의미합니다. 한 번에 받을 수 있는 최대 개수는 2,000개입니다.
getheaders 메시지를 나타내는 GetHeadersMessage 구조체는 다음과 같습니다.
type GetHeadersMessage struct {
Version int32
NumberOfHashes int64
StartBlock []byte
EndBlock []byte
}
func DefaultGetHeadersMessage() *GetHeadersMessage {
msg := &GetHeadersMessage{
Version: 70015,
NumberOfHashes: 1,
EndBlock: bytes.Repeat([]byte{0x00}, 32),
}
return msg
}
func NewGetHeadersMessage(version int32, numberOfHashes int64, startBlock []byte, endBlock []byte) (*GetHeadersMessage, error) {
msg := DefaultGetHeadersMessage()
if version != 0 {
msg.Version = version
}
if numberOfHashes > 0 {
msg.NumberOfHashes = numberOfHashes
}
if startBlock == nil {
return nil, ErrInvalidStartBlockHash
}
if len(startBlock) != 32 {
return nil, ErrInvalidStartBlockHash
}
msg.StartBlock = startBlock
if endBlock != nil {
if len(endBlock) != 32 {
return nil, ErrInvalidEndBlockHash
}
msg.EndBlock = endBlock
}
return msg, nil
}
func (ghm GetHeadersMessage) Command() Command {
return GetHeadersCommand
}
func (ghm GetHeadersMessage) Serialize() ([]byte, error) {
result := utils.IntToLittleEndian(int(ghm.Version), 4)
result = append(result, utils.EncodeVarint(int(ghm.NumberOfHashes))...)
result = append(result, ghm.StartBlock...)
result = append(result, ghm.EndBlock...)
return result, nil
}
3.2 블록 헤더 응답
getheaders 메시지를 보내면 상대 노드는 'headers' 명령어를 가진 메시지로 응답합니다. 이 메시지의 형식은 다음과 같습니다.
블록 헤더의 개수 | varint |
블록 헤더 | 80 bytes, little-endian |
트랜잭션 수 | 1 byte, always 0 |
headers 메시지는 1~2000 범위의 블록 헤더의 개수로 시작합니다. 블록 헤더는 80바이트 크기이며 블록 헤더에 이어 트랜잭션의 수가 명시되는데, 헤더의 값만 가져왔기 때문에 이 값은 항상 0입니다. 이런 식으로 트랜잭션 수를 굳이 명시해 준 이유는 블록 메시지와 호환하기 위함입니다.
headers 메시지를 나타내는 HeadersMessage 구조체는 다음과 같습니다.
type HeadersMessage struct {
NumberOfHeaders int64
Headers []*block.Block
}
func (hm HeadersMessage) Command() Command {
return HeadersCommand
}
func (hm HeadersMessage) Serialize() ([]byte, error) {
return nil, nil
}
func ParseHeadersMessage(b []byte) (*HeadersMessage, error) {
buf := bytes.NewBuffer(b)
numOfHeaders, read := utils.ReadVarint(buf.Bytes())
buf.Next(read)
headers := make([]*block.Block, numOfHeaders)
for i := 0; i < int(numOfHeaders); i++ {
header, err := block.Parse(buf.Bytes())
if err != nil {
return nil, err
}
headers[i] = header
numOfTxns, _ := utils.ReadVarint(buf.Bytes())
if numOfTxns > 0 {
return nil, fmt.Errorf("block %d has %d transactions", i, numOfTxns)
}
buf.Next(81)
}
return &HeadersMessage{
NumberOfHeaders: int64(numOfHeaders),
Headers: headers,
}, nil
}
3.3 블록 헤더 요청 및 검증 실행
여기까지 오는데 우여곡절이 많았습니다만... 다시 큰 벽에 가로막히고 말았습니다. 책의 예제 코드에서는 메인넷과 연결하여 헤더 정보를 받아 오는데, 제가 작성한 코드로는 아무리 해봐도 메인넷하고는 핸드셰이크조차 제대로 진행되지 않았습니다.
그래서 테스트넷으로 변경하여 코드를 실행했는데 핸드셰이크는 잘 되지만 블록 헤더를 가져오는 부분에서 오류가 발생했습니다. 시작 블록만 명시해 주고 마침 블록을 명시해주지 않았기 때문에 2,000개 가까이 되는 헤더를 받아올 텐데, 로깅을 해서 살펴보니 한 번에 보내주는 것이 아니라 끊어치는 식으로 보내주는 것이 아니겠습니까? 이 부분이 파이썬에서는 어떻게 동작하는지 잘 모르겠지만(솔직히 말이 안된다고 생각) go로 처리를 하려니 다소 복잡한 부분이 있습니다.
그래서 블록 헤더를 요청하고 검증하는 것은 숙제로 남겨놓고 책을 마무리한 뒤에 다시 돌아와서 해결하도록 하겠습니다!
** 1월 17일 추가 **
블록 헤더 요청과 관련된 문제 해결 과정을 아래 게시글에 기술하였습니다.
📖 참고자료
글에서 수정이 필요한 부분이나 설명이 부족한 부분이 있다면 댓글로 남겨주세요!
'블록체인 > Bitcoin' 카테고리의 다른 글
밑바닥부터 시작하는 비트코인 - 12장 블룸 필터 (1) | 2024.01.10 |
---|---|
밑바닥부터 시작하는 비트코인 - 11장 단순 지급 검증 (1) | 2024.01.09 |
밑바닥부터 시작하는 비트코인 - 9장 블록 (0) | 2023.09.21 |
밑바닥부터 시작하는 비트코인 - 8장 p2sh 스크립트 (0) | 2023.09.20 |
밑바닥부터 시작하는 비트코인 - 7장 트랜잭션 검증과 생성 (1) | 2023.09.16 |