Series
아무래도 군대에 있다보니 신기술에 관심히 많이 생기는 거 같다. 이번에는 Rust로 블록체인을 만들어 보려고 한다. 이 블록체인 튜토리얼 자체는 https://github.com/mingrammer/blockchain-tutorial 에서 따온 튜토리얼인데, 뭔가 그대로 따라하면 아예 배우는게 없을거 같아서 Rust도 최근에 공부좀 할겸 go project를 rust로 옮기면서 적어보려고 한다. 그리고 만약에 시리즈 끝까지 진행하면 나중에는 다른 시리즈로 이 프로토타입을 좀더 발전 시켜서 비트코인 spec에 맞춰서 구현해보는 것 까지 염두하고 있다.
[1] Rust로 블록체인 만들어보기 - 기본 프로토타입
[2] Rust로 블록체인 만들어보기 - 작업증명 (Power of Work)
[3] Rust로 블록체인 만들어보기 - Persistent(영속성) 부여하기
Introduction
세 번째 장에서는 우리가 만들 블록체인에 영속성을 부여해 볼 예정이다. 아직까지 우리가 만든 블록체인에는 현재 영구적으로 데이터를 저장하는 알고리즘이 없다. 따라서 매번 프로젝트를 시작할 때 마다 생성한 블록들이 날라가게 되는데, 이를 앞으로는 저장할 DB랑 연동해서 블록체인에 영속성을 부여할 생각이다.
3장의 전체 코드는 다음 링크에서 확인할 수 있다.
GitHub - chungjung-d/simplecoin
Contribute to chungjung-d/simplecoin development by creating an account on GitHub.
github.com
Persistent(영속성)
두 번째 장까지 우리는 채굴과 그 검증이 가능한 블록체인을 구현했다. 그러나 우리의 블록체인에는 매우 큰 문제점이 한 가지 있다. 바로 영구적인 정보의 저장이 불가능하다는 것이다. 현재 채굴한 블록의 모든 결과는 실행시키는 컴퓨터의 메모리에 떠 있는 상태에 불과하며 프로그램을 종료할 시 모든 정보가 휘발되는 문제가 발생한다. 그를 방지하기 위해서 우리는 블록체인에 KV Database를 연결해서 디스크에 저장하여 영속성을 보장하는 작업을 진행할 것이다 .
RocksDB
그를 위해서 우리는 적당히 가볍고 나름의 고성능이며 구현하기 간단한 RocksDB를 이용할 것이다.
RocksDB | A persistent key-value store
RocksDB is an embeddable persistent key-value store for fast storage.
rocksdb.org
원래는 참고한 blockchain tutorial github에서 사용한 것 처럼, 간단하게 구현할 때 DB로 많이 사용되는 BoltDB를 사용하려 했으나, BoltDB에는 몇 가지 문제점이 존재한다. 우선 첫 번째로 archived 되었다는 사실이고, 두 번째로 BoltDB는 golang을 사용할 때 사용하기 편하고 rust로 이를 사용하려고 했을 때에 대한 자료를 찾을 수 없었다.
(저의 검색능력이 부족한가 봅니다…)
대안으로 facebook에서 만든 RocksDB는 자료를 찾을 수 있었고, 나름 간단하게 사용할 수 있었다. 거기에 KV database로 개발되어 기본적으로 간단하며, 별도의 서버를 띄워야 하는 타 DB와 다르게 sqlite마냥 서버가 필요없었다.
Bitcoin’s Database structure
먼저 Bitcoin의 경우에는 다음과 같은 방식으로 DB를 설계해서 persistent를 보장하고 있다.
자세한 설명은 이 링크를 참고하면 볼 수 있다.
비트코인은 우선 두 개의 bucket을 가진다.
blocks bucket : 체인상의 블록에 대한 정보를 저장한다. 이 버킷은 다음과 같은 KV를 가진다.
'b' + 32바이트 블록 해시 → 블록 인덱스 레코드
'f' + 4바이트 파일 번호 → 파일 정보 레코드
'l' → 4바이트 파일 번호: 마지막으로 사용된 블록의 파일 번호
'R' → 1바이트 부울: 재색인 작업 진행 여부
'F' + 1바이트 플래그명 길이 + 플래그명 → 1바이트 부울: 끄고 켤 수 있는 여러 플래그들
't' + 32바이트 트랙잭션 해시 → 트랜잭션 인덱스 레코드
chainstate bucket : 체인의 상태에 대한 정보를 저장한다. 처리되지 않은 트랜잭션 데이터도 여기에 포함된다. 이 버킷은 다음과 같은 KV를 가진다.
'c' + 32바이트 트랜잭션 해시 → 해당 트랜잭션에 대한 미사용 트랜잭션 출력 레코드
'B' → 32바이트 블록 해시: 데이터베이스가 미사용 트랜잭션 출력을 나타내는 블록 해시
다만 우리의 블록체인은 이를 조금 더 단순화 시킬 예정이기 때문에, blocks bucket안에 있는 다양한 key를 그냥 block_hash → block encoding data 그리고 l → blockchain’s last hash 정도로 처리할 예정이다.
Serialization
아까 Block의 hash값을 key로 널고 value에 들어갈 데이터는 block을 인코딩 한 데이터를 넣을 것이라고 했다. 따라서 block의 값을 직렬화 하는 과정이 필요한데, 이를 이용하기 위해서 serde crate를 사용했다. cargo.toml파일에 아래 dependency를 추가한다.
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_derive = "1.0.181"
그리고 block을 구현 한 코드에 method로 serialize와 deserialize를 둘 다 구현한다.
//block.rs
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Block {
timestamp: i64,
data: Vec<u8>,
prev_block_hash: Vec<u8>,
hash: Vec<u8>,
nonce: u32,
}
impl Block {
pub fn serialize(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("Failed to serialize data")
}
pub fn deserialize(d: &[u8]) -> Block {
serde_json::from_slice(d).expect("Failed to deserialize data")
}
}
이렇게 선언함으로써 우선 block을 byte로 인코딩하고 다시 디코딩해서 block을 복구하는 알고리즘까지는 완료했다. 이제 직렬화를 완성했으니, DB랑 직접 연결하고 Blockchain 생성 과정에서 어떻게 사용할 것인지 구현해 보려고 한다.
Blockchain with database
블록체인에 있는 블록 메타데이터를 이제 RocksDB에 저장하는 알고리즘을 작성할 것이다. 먼저 해야할 것은 Blockchain의 struct를 바꾸는 것이다.
//blockchain.rs
use rocksdb::{Options, WriteBatch, DB};
use std::sync::Arc;
pub struct BlockChain {
tip: Vec<u8>,
database: Arc<DB>,
}
tip
은 블록체인에서의 가장 마지막 블록의 hash값을 나타내고, database는 연결할 DB connection에 대한 정보를 저장한다. Arc를 쓴 이유는 추후 db conncetion에 대한 정보를 다른 struct에서도 사용하기 때문에 이용했다. 다만 이 부분에 대해서는 필자도 완벽하게 이해하고 사용한 것은 아니기 때문에 문제가 될 시 추후 수정될 수 있다.
위와 같이 blockchain의 struct를 바꾼 이유는 우리가 구현할 로직이 다음과 같기 떄문이다.
- DB 파일을 연다.
- 저장된 블록체인이 있는지 확인한다. (”l” 을 가장 마지막 블록체인의 hash로 지정했으므로 이를 확인한다.
- 만약 저장된 블록체인이 존재하면 블록체인 인스턴스를 생성하고, blockchain의 tip을 마지막 블록체인의 hash값으로 작성한다.
- 만약 저장된 블록체인이 없다면
- Genesis block을 생성한다.
- DB에 저장한다.
- Genesis block의 hash를 마지막 블록의 hash로 지정하고 “l” key의 value 값에 넣는다.
- Genesis block의 hash 를 tip으로 가지는 블록체인의 인스턴스를 생성한다.
이를 구현하면 다음과 같다.
//blockchain.rs
impl BlockChain {
pub fn new() -> BlockChain {
let tip: Vec<u8>;
let path = "./blockchain.db";
let mut options = Options::default();
options.create_if_missing(true);
let database = Arc::new(DB::open(&options, path).unwrap());
let last_hash = database.get(b"l").unwrap();
if last_hash.is_none() {
let genesis_block = BlockChain::new_genesis_block();
let genesis_hash = genesis_block.hash().to_vec();
database
.put(&genesis_hash, &genesis_block.serialize())
.unwrap();
database.put(b"l", &genesis_hash).unwrap();
tip = genesis_hash;
} else {
tip = last_hash.unwrap().to_vec();
}
BlockChain {
tip: tip,
database: database,
}
}
}
자세히 보면 database
객체에 DB conncection에 대한 정보를 담아서 저장함을 볼 수 있다. 그리고 last_hash
값에 “l” key로 저장된 값을 불러오고 값이 있다면 그대로 그 값을 tip에 넣고 그게 아니라면 위에 설명한 과정의 4번 과정에 따라서 값을 설정하고 BlockChain
인스턴스를 돌려주는 것을 볼 수 있다.
두 번째로 수정해야 하는 method는 블록체인에 블록을 추가하는 코드이다.
//blockchain.rs
impl BlockChain {
pub fn add_block(&mut self, data: &str) {
let prev_block_hash: Vec<u8> = self.database.get(b"l").unwrap().unwrap().to_vec();
let new_block = Block::new(data, &prev_block_hash);
let mut batch = WriteBatch::default();
batch.put(&new_block.hash(), &new_block.serialize());
batch.put(b"l", &new_block.hash());
self.database.write(batch).unwrap();
self.tip = new_block.hash().to_vec();
}
}
이렇게 작성하면 새로운 블록을 추가할 때마다 데이터를 직렬화해서 DB에 저장하고, 블록체인의 가장 끝 블록의 hash값을 매번 “l” key에 대한 value로 갱신시켜주는 것을 볼 수 있다. 또한 blockchain의 tip또한 갱신시켜준다.
Blockchain Search
이렇게 설계한 블록체인에는 한 가지 문제점이 있다. 바로 기존과는 다르게 배열에 block을 저장하지 않기 때문에 search로직이 달라진다는 점이다. 이를 위해서 몇 가지 코드를 추가할 필요성이 있다.
Blockchain Iterator
우리는 탐색을 쉽게 하기 위해서 탐색 전용 Iterator를 직접 구현했다.
//blockchain.rs
pub struct BlockchainIterator {
current_hash: Vec<u8>,
database: Arc<DB>,
}
impl BlockchainIterator {
pub fn new(database: Arc<DB>, current_hash: Vec<u8>) -> BlockchainIterator {
BlockchainIterator {
current_hash: current_hash,
database: database,
}
}
pub fn next(&mut self) -> Option<Block> {
let encoded_block = self.database.get(&self.current_hash).unwrap();
if encoded_block.is_none() {
return None;
}
let block = Block::deserialize(&encoded_block.unwrap());
self.current_hash = block.prev_block_hash().to_vec();
Some(block)
}
}
블록체인 Iterator를 만들고 next를 통해서 이전의 블록을 불러와서 linked list 탐색하듯이 전체 탐색을 구현할 수 있는 알고리즘을 method로 만들었다. 그리고 이 Iterator를 생성하기 위한 meothod를 blockchain method에 넣어서 적용시켰다.
impl BlockChain {
pub fn iterator(&self) -> BlockchainIterator {
BlockchainIterator::new(Arc::clone(&self.database), self.tip.clone())
}
}
이렇게 하면 우리가 원하는 탐색의 구현은 끝났다. 그렇다면 이제 2장에 비해서 달라진 파일의 전체 코드를 확인해보자.
Final code
block.rs
use crate::pow::ProofOfWork;
use chrono::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Block {
timestamp: i64,
data: Vec<u8>,
prev_block_hash: Vec<u8>,
hash: Vec<u8>,
nonce: u32,
}
impl Block {
pub fn new(data: &str, prev_block_hash: &[u8]) -> Block {
let mut block = Block {
timestamp: Utc::now().timestamp(),
data: data.as_bytes().to_vec(),
prev_block_hash: prev_block_hash.to_vec(),
hash: vec![],
nonce: 0,
};
let pow = ProofOfWork::new(&block, 8);
let (nonce, hash) = pow.run();
block.hash = hash;
block.nonce = nonce;
block
}
pub fn serialize(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("Failed to serialize data")
}
pub fn deserialize(d: &[u8]) -> Block {
serde_json::from_slice(d).expect("Failed to deserialize data")
}
pub fn hash(&self) -> &[u8] {
&self.hash
}
pub fn timestamp(&self) -> i64 {
self.timestamp
}
pub fn prev_block_hash(&self) -> &[u8] {
&self.prev_block_hash
}
pub fn data(&self) -> &[u8] {
&self.data
}
pub fn nonce(&self) -> u32 {
self.nonce
}
}
위의 파일에는 serialize와 deserialize method 두 개가 추가되어서 블록을 저장하고 다시 불러오는데 있어서 사용되는 method를 구현했다.
Blockchain.rs
use crate::block::Block;
use rocksdb::{Options, WriteBatch, DB};
use std::sync::Arc;
pub struct BlockChain {
tip: Vec<u8>,
database: Arc<DB>,
}
pub struct BlockchainIterator {
current_hash: Vec<u8>,
database: Arc<DB>,
}
impl BlockChain {
pub fn new() -> BlockChain {
let tip: Vec<u8>;
let path = "./blockchain.db";
let mut options = Options::default();
options.create_if_missing(true);
let database = Arc::new(DB::open(&options, path).unwrap());
let last_hash = database.get(b"l").unwrap();
if last_hash.is_none() {
let genesis_block = BlockChain::new_genesis_block();
let genesis_hash = genesis_block.hash().to_vec();
database
.put(&genesis_hash, &genesis_block.serialize())
.unwrap();
database.put(b"l", &genesis_hash).unwrap();
tip = genesis_hash;
} else {
tip = last_hash.unwrap().to_vec();
}
BlockChain {
tip: tip,
database: database,
}
}
pub fn add_block(&mut self, data: &str) {
let prev_block_hash: Vec<u8> = self.database.get(b"l").unwrap().unwrap().to_vec();
let new_block = Block::new(data, &prev_block_hash);
let mut batch = WriteBatch::default();
batch.put(&new_block.hash(), &new_block.serialize());
batch.put(b"l", &new_block.hash());
self.database.write(batch).unwrap();
self.tip = new_block.hash().to_vec();
}
pub fn iterator(&self) -> BlockchainIterator {
BlockchainIterator::new(Arc::clone(&self.database), self.tip.clone())
}
fn new_genesis_block() -> Block {
let block = Block::new("Genesis Block", &vec![]);
block
}
}
impl BlockchainIterator {
pub fn new(database: Arc<DB>, current_hash: Vec<u8>) -> BlockchainIterator {
BlockchainIterator {
current_hash: current_hash,
database: database,
}
}
pub fn next(&mut self) -> Option<Block> {
let encoded_block = self.database.get(&self.current_hash).unwrap();
if encoded_block.is_none() {
return None;
}
let block = Block::deserialize(&encoded_block.unwrap());
self.current_hash = block.prev_block_hash().to_vec();
Some(block)
}
}
위의 코드에는 많은 변경사항이 있었다. 우선 모든 블록을 저장하지 않고, 블록의 정보를 저장한 DB를 참조하는 방식으로 코드들이 바뀌었으며 그에 따라서 앞에 말했던 방식으로 탐색, 추가, 생성의 로직이 변경되었다.
main.rs
pub mod block;
pub mod blockchain;
pub mod pow;
use hex;
fn main() {
let mut bc = blockchain::BlockChain::new();
bc.add_block("Send 1 BTC to Ivan");
bc.add_block("Send 2 more BTC to Ivan");
let mut iterator = bc.iterator();
loop {
match iterator.next() {
Some(block) => {
println!("Prev. hash: {}", hex::encode(block.prev_block_hash()));
println!(
"Data: {}",
String::from_utf8(block.data().to_vec()).unwrap()
);
println!("Hash: {}", hex::encode(block.hash()));
let pow = pow::ProofOfWork::new(&block, 8);
println!("PoW: {}", pow.validate());
println!("");
}
None => break,
}
}
}
실제로 위와 같이 바뀐 코드에 맟춰 main.rs파일도 바뀌어야 했다. 확인해보면 탐색하는 방식을 아까 구현했던 iterator를 쓰는 것을 볼 수 있으며, 실제로 이 crate를 여러번 실행하면 기존 블록에 계속 블록이 더해지고 기존에 생성했던 블록이 삭제되지 않고 출력되는 사실을 확인할 수 있다.
Outro
다음 장에서는 이제 우리 블록체인의 cli를 구성할 생각이다. 명령어를 통해서 블록체인을 제어하고 작성하는 방법을 작성할 것이다.
'Blockchain' 카테고리의 다른 글
Rust로 블록체인 만들어보기 [2] - 작업증명 (Power of Work) (1) | 2023.07.22 |
---|---|
Rust로 블록체인 만들어보기 [1] - 기본 프로토타입 (0) | 2023.07.22 |