SKKRYPTO 7기 김용입니다. 저번시간에는 P2P가 무엇인지에 대해 알아보았습니다. 이번 시간에는 P2P기반의 블록체인 네트워크를 GO라는 프로그래밍언어를 통해 구현해 보도록 하겠습니다.
채굴 노드는 새로운 블록을 최대한 빠르게 채굴하는 것이다. 채굴 노드는 작업 증명 시스템을 이용하는 블록체인에서만 가능하다.(블록체인은 작업증명 시스템이다.)
이 노드는 채굴 노드에 의해 채굴된 블록의 유효성을 확인하고 트랜잭션을 검증한다. 또한 풀 노드는 다른 노드들이 서로를 찾을 수 있도록 돕는 일과 같은 라우팅 연산을 수행하기도 한다.
이 노드는 트랜잭션 검증을 할 수 있다. SPV 노드는 풀 노드로부터 데이터를 얻어오며 하나의 풀 노드에 여러개의 SPV 노드가 연결될 수 있다. SPV는 지갑 애플리케이션을 가능케 한다.
노드는 메시지를 통해 통신한다. 새로운 노드가 실행되면 DNS 시드에서 여러 노드를 가져와 version 메시지를 전송한다. 메시지는 다음과 같이 구현할 수 있다.
type version struct {
Version int
BestHeight int
AddrFrom string
}
BestHeight는 노드가 가진 블록체인의 길이를 저장한다.
AddrFrom은 전송자의 주소를 저장한다.
version 메시지를 어떤 노드가 받았다면 이 노드는 자신의 version 메시지로 응답해야 한다.
version은 더 긴 블록체인을 찾는데 사용된다. 노드가 version 메시지를 받으면 이는 자신이 가진 블록체인이 BestHeight보다 더 긴지 확인한다. 더 짧은 경우, 노드는 누락된 블록을 요청하여 다운로드 받는다.
메시지를 받기 위해서는 서버가 필요하며 아ㄹ는 서버를 구현한 코드이다.
var nodeAddress string
var knownNodes = []string(“localhost:3000”)
func StartServer(nodeID, minerAddress string) {
nodeAddress = fmt.Sprintf(“localhost:%s”, nodeID)
miningAddress = minerAddress
In, err := net.Listen(protocol, nodeAddress)
defer In.Close()
bc := NewBlockchain(nodeID)
if nodeAddress != knownNodes[0] {
sendVersion (knownNodes[0], bc)
)
for {
conn, err := ln.Accept()
go handleConnection(conn, bc)
}
}
먼저 중앙 노드의 주소를 코딩한다. 모든 노드는 처음에 연결할 노드를 알아야 한다.
minerAddress 인자는 채굴 보상을 받을 주소를 지정한다.
if nodeAddress != knownNodes[0] {
sendVersion(knownNodes[0], bc)
}
현재 노드가 중앙노드가 아니면 자신의 블록체인이 최신 데이터인지 확인하기 위해 중앙 노드에 version 메시지를 전송해야 한다.
func sendVersion(addr string, bc *Blockchain) {
bestHeight := bc.GetBestHeight()
payload := gobEncode(version(nodeVersion, bestHeight, nodeAddress))
request := append(commandToBytes(“version”), payload...)
sendData(addr, request)
}
메시지는 바이트의 연속이다. 첫 12 바이트는 커맨드명을 나타내며 이어지는 바이트는 gob으로 인코딩된 메시지 구조체이다. commandToBytes는 다음과 같이 구현한다.
func commandToBytes(command string) []bytes {
var bytes [commandLength]byte
for I, c := range command {
bytes[i] = byte(c)
}
return bytes[:]
}
이 함수는 12 바이트의 버퍼를 만들어 커맨드명을 채워넣은 뒤 나머지 바이트는 빈 상태 그대로 둔다. 반대로 바이트 시퀀스를 커맨드로 변환하는 함수도 있다.
func bytesToCommand(bytes []byte) string {
var command []byte
for _, b := range bytes {
if b != 0x0 { command = append(command, b)
}
}
return fmt.Sprintf(“%s”, command)
}
노드가 커맨드를 수신하면, bytesToCommand를 통해 커맨드 명을 가져와 적절한 핸들러로 커맨드 내용을 처리한다.
func handleConnection(conn net.Conn, bc *Blockchain) {
request, err := ioutil.ReadAll(conn)
command := bytesToCommand(request[:commandLengthj])
fmt.Printf(“Received %s command\n”, command)
switch command {
...
case “version”:
handleVersion(request, bc)
default:
릇.Println(“Unknown command!”)
}
conn.Close()
}
version 커맨드 핸들러는 다음과 같다.
func handleVersion(request []byte, bc *Blockchain) {
var buff bytes.Buffer
var payload version
buff.Write(request[commandLength:])
dec := gob.NewDecoder(&buff)
err := dec.Decode(&payload)
myBestHeight := bc.GetBestHeight()
foreignerBestHeight := payload.BestHeight
if myBestHeight < foreignerBestHeight {
sendGetBlocks(payload.AddrFrom)
} else if myBestHeight > foreignerBestHeight {
sendVersion(payload.AddrFrom, bc)
}
if !nodeIsKnown(payload.AddrFrom) {
knownNodes = append(knownNodes, payload.AddrFrom)
}
}
type getblocks struct {
AddFrom string
}
func handleGetBlocks(request []byte, bc *Blockchain) {
...
blocks := bc.GetBlockHashes()
sendInv(payload. AddrFrom, "block", blocks)
}
type inv struct {
AddrFrom string
Type string
Items [][]byte
}
func handleInv(request []byte, bc *Blockchain) {
...
fmt.Printf("Received inventory with %d %s\n", len(payload.Items), payload.Type)
if payload.Type == "block" {
blocksInTransit = payload.Items
blockHash := payload.Items[0]
sendGetData(payload.AddrFrom, "block", blockHash)
newInTransit := [][]byte{}
for _, b := range blocksInTransit {
if bytes.Compare(b, blockHash) != 0 {
newInTransit = append(newInTransit, b)
}
}
blocksInTransit = newInTransit
}
if payload.Type == "tx" {
txID := payload.Items[0]
if mempool[hex.EncodeToString(txID)].ID == nil {
sendGetData(payload.AddrFrom, "tx", txID)
}
}
}
블록 해시값들을 전달받으면 다운로드한 블록을 추적하기 위해 blockInTransit 변수에 저장한다. 이렇게 하면 다른 노드에서 블록을 다운로드 할 수 있다. 블록을 전송상태로 전환한 직후에 inv 메시지를 보낸 노드에게 getdata커맨드를 전송하고 blockInTransit을 갱신한다. 실제 P2P네트워크에서는 메시지를 보낸 노드만이 아니라 서로 다른 노드에서 블록을 전송하려고 한다.
type getdata struct {
AddrFrom string
Type string
ID []byte
}
func handleGetData(request []byte, bc *Blockchain) {
...
if payload.Type == "block" {
block, err := bc.GetBlock([]byte(payload.ID))
sendBlock(payload.AddrFrom, &block)
}
if payload.Type == "tx" {
txID := hex.EncodeToString(payload.ID)
tx := mempool[txID]
sendTx(payload.AddrFrom, &tx)
}
}
type block struct {
AddrFrom string
Block []byte
}
type tx struct {
AddrFrom string
Transaction []byte
}
func handleBlock(request []byte, bc *Blockchain) {
...
blockData := payload.Block
block := DeserializeBlock(blockData)
fmt.Println("Received a new block!")
bc.AddBlock(block)
fmt.Printf("Added block %x\n", block.Hash)
if len(blocksInTransit) > 0 {
blockHash := blocksInTransit[0]
sendGetData(payload.AddrFrom, "block", blockHash)
blocksInTransit = blocksInTransit[1:]
} else {
UTXOSet := UTXOSet{bc}
UTXSOSet.Reindex()
}
}
func handleTx(request []byte, bc *Blockchain) {
...
txData := payload.Transaction
tx := DeserializeTransaction(txData)
mempool[hex.EncodeToString(tx.ID)] = tx
if nodeAddress == knownNodes[0] {
for _, node := range knownNodes {
if node != nodeAddress && node != payload.AddrFrom {
sendInv(node, "tx", [][]byte{tx.ID})
}
}
} else {
if len(mempool) >= 2 && len(miningAddress) > 0 {
MineTransactions:
var txs []*Transaction
for id := range mempool {
tx := mempool[id]
if bc.VerifyTransaction(&tx) {
txs = append(txs, &tx)
}
}
if len(txs) == 0 {
fmt.Println("All transactions are invalid! Waiting for new ones...")
return
}
cbTx := NewCoinbaseTX(miningAddress, "")
txs = append(txs, cbTx)
newBlock := bc.MineBlock(txs)
UTXOSet := UTXOSet{bc}
UTXOSet.Reindex()
fmt.Println("New block is mined!")
for _, tx := range txs {
txID := hex.EncodeToString(tx.ID)
delete(mempool, txID)
}
for _, node := range knownNodes {
if node != nodeAddress {
sendInv(node, "block", [][]byte{newBlock.Hash})
}
}
if len(mempool) > 0 {
goto MineTransactions
}
}
}
}
처음에 할 일은 새로운트랜잭션을 mempool에 추가하는 것이다. 다음 코드를 살펴보자.
if nodeAddress == knownNodes[0] {
for _, node := range knownNodes {
if node != nodeAddress && node != payload.AddFrom {
sendInv(node, "tx", [][]byte{tx.ID})
}
}
}
현재 노드가 중앙 노드인지를 확인한다. 중앙노드는 블록을 채굴하지 않는다. 대신 새로운 트랜잭션을 네트워크 상의 다른 노드들에게 전달해 준다.
if len(mempool) >= 2 && len(miningAddress) > 0 {}
miningAddress은 채굴자 노드에만 있다. 현재 노드의 mempool에 두 개 이상의 트랜잭션이 있을 때 채굴을 시작한다.
for id := range mempool {
tx := mempool[id]
if bc.VerifyTransaction(&tx) {
txs = append(txs, &tx)
}
}
if len(txs) == 0 {
fmt.Println("All transactions are invalid! Waiting for new ones...")
return
}
mempool의 모든 트랜잭션이 검증된다. 유효하지 않은 트랜잭션을 무시되며, 유효한 트랜잭션이 없는 경우 채굴은 중단된다.
cbTx := NewCoinbaseTX(miningAddress, "")
txs = append(txs, cbTx)
newBlock := bc.MineBlock(txs)
UTXOSet := UTXOSet{bc}
UTXOSet.Reindex()
fmt.Println("New block is mined!")
검증된 트랜잭션은 보상을 가진 코인베이스 트랜잭션과 함께 블록에 추가된다. 블록 채굴이 끝나면 UTXO집합은 재색인 된다.
for _, tx := range txs {
txID := hex.EncodeToString(tx.ID)
delete(mempool, txID)
}
for _, node := range knownNodes {
if node != nodeAddress {
sendInv(node, "block", [][]byte{newBlock.Hash})
}
}
if len(mempool) > 0 {
goto MineTransactions
}
트랜잭션은 채굴된 후 mempool에서 제거된다. 현재 노드가 알고 있는 모든 노드들은 새로운 블록해시가 담긴 inv메시지를 받는다. 이 노드들은 메시지를 처리한 후에 블록을 요청할 수 있다.