blockchain 블록 typescript로 구현해 보기 feat.jest

이무헌·2023년 9월 4일
0

blcokchain

목록 보기
2/10
post-thumbnail

1.오늘 할 것

블록체인에서 블록이 생성되는 원리에 대해 코드로 구현해볼 생각은 하고 있었다. 언젠가
하지만 우연치 않게 이번 수업에서 블록을 typescript로 구현하신다 하셔서 속으로 쾌재를 불렀다.
블록 작동 원리는 다음과 같이 요약할 수 있다.

genesis블록 생성 요청=>해당 블록에 대한 header,data를 hash화 한다.=>이를 2진법으로 바꿔(기존 16진법) 난이도에 적합한지 테스트=>성공한다면 그 hash를 저장하고 block 최종 생성

위 과정을 계속 반복하는게 채굴이다. 그리고 블록의 생성이다.

2.코드로 이해하기

02.Block부분만 보면 된다. tsconfig.json은 예전과 같고, jest.config.ts는 unit test를 위해 설치받은 라이브러리 jest의 설정 파일이다.

1.interface

1.block.interface.ts

먼저 인터페이스에서 만들 코드의 구조를 정의하자


export interface IBlockHeader {
  version: string;
  height: number;
  timestamp: number;
  previousHash: string;
}

export interface IBlock extends IBlockHeader {
  merkleRoot: string;
  hash: string;
  nonce: number;
  difficulty: number;
  data: string[];
}

=>IBlockHeader 는 블록의 헤더부분이다. 이는 곧 블록에 종속되어있다. (블록은 header+body이므로) 그러므로 이 IBlockHeader를 상속받는 곳은 header+body 부분인 IBlock이다.

1.version:블록의 버전

2.height:블록의 높이

3.timestamp:블록 생성 시간

4.previousHash:전 블록의 해쉬값

5.merkleRoot:머클 알고리즘으로 생성된 body의 데이터 hash값

6.hash:현 블록의 hash값(즉, 채굴이 성공되면 저장)

7.nonce:채굴 횟수(이nonce가 1씩 올라가므로 제출하는 답이 달라지게 되는 것이다.)

8.difficulty:난이도(있어야 할 0의 개수)

9.data:블록에 저장하고 싶은 데이터

2.failable.interface.ts

테스트시 블록의 유효성이 실패했을경우 검증해주는 모듈이다.

export interface Result<T> {
  isError: false;
  value: T;
}

export interface Failure<T> {
  isError: true;
  value: T;
}

export type Failable<T1, T2> = Result<T1> | Failure<T2>;

T는 제네릭타입으로 (hi java) 어떤 타입이 들어올지 구현단계에서 정해주면 된다.

2.block

1.blockHeader.ts

import { IBlock, IBlockHeader } from "@core/interface/block.interface";
class BlockHeader implements IBlockHeader {
  version: string;
  height: number;
  timestamp: number;
  previousHash: string;
  constructor(_previousBlock: IBlock) {
    // 블록을 생성할 때 이전 블록의 정보가 필요하다
    // 이전 블록의 해시나 높이나
    this.version = BlockHeader.getVersion();
    this.timestamp = BlockHeader.getTimeStamp();
    this.height = _previousBlock.height + 1;
    this.previousHash = _previousBlock.hash;

  }

  static getVersion() {
    return "1.0.0";
  }
  static getTimeStamp() {
    return new Date().getTime();
  }
}

export default BlockHeader

블록 헤더를 정의하는 부분이다.
_previousBlock: IBlock 부분에서 우리는 전 블록에 대한 정보가 헤더에 저장됨을 알 수 있다.즉,전 블록에서 채굴이 완벽하게 끝나고 나온 최종 해쉬값이 이 곳 header에 저장된다.

2.block.ts

import { SHA256 } from "crypto-js";
import merkle from "merkle";
import BlockHeader from "./blockHeader";
import { IBlock } from "@core/interface/block.interface";
import { Failable } from "@core/interface/failable.interface";
import CryptoModule from "@core/crypto/crypto.module";

// block의 형태를 클래스로 정의
class Block extends BlockHeader implements IBlock {
  hash: string;
  merkleRoot: string;
  nonce: number;
  difficulty: number;
  data: string[];
  constructor(_previousBlock: Block, _data: string[]) {
    // 부모 클래스 생성자 호출 super를 사용함
    super(_previousBlock);
    this.merkleRoot = Block.getMerkleRoot(_data);

    // 지금은 0으로
    this.nonce = 0;
    // 지금은 난이도 3
    this.difficulty = 3;
    this.data = _data;
    // 블록 본인의 데이터를 해시화 한게 블록의 해시값
    this.hash = Block.createBlockHash(this);
  }

  //   블록 추가
  static generateBlock(_previousBlock: Block, _data: string[]): Block {
    const generateBlock = new Block(_previousBlock, _data);
    // 마이닝을 통해서 블록의 생성 권한을 받은 블록을 만들고
    const newBlock = Block.findBlock(generateBlock);
    return newBlock;
  }

  //   마이닝 작업 코드
  // 블록의 채굴
  // 연산을 통해서 난이도의 값에 따른 정답을 찾는 동작
  //   POW: 작업 증명 블록의 난이도에 총족하는값을 구하기 위해서 연산작업을 계속 진행하여 조건에 충족하는 값을 구하면
  // 보상으로 블록의 생성권한을 얻는다.
  static findBlock(generateBlock: Block) {
    let hash: string;
    // nonce 변수는 블록의 채굴을 하는데 연산을 몇번 진행했는지 값을 여기에 담을 것임
    let nonce: number = 0;

    while (true) {
      generateBlock.nonce = nonce;
      nonce++;
      //   블록 해시 구하는 구문 추가
      hash = Block.createBlockHash(generateBlock);

      //   16진수=>2진수로 변환해야한다.
      //   16진수를 2진수로 변환해서 0의 개수가 난이도의 개수에 충족하는지 체크
      // 블록 채굴의 권한을 받는다.
      // 성공시 while탈출=> 채굴 성공

      const binary: string = CryptoModule.hashToBinary(hash);
      // binary값 안에 있는 nonce를 증가시키기 때문에 => hash값이 바뀜 =>즉, binary값도 바뀌게 됨

      console.log("binary:", binary);
      //   연산의 값이 난이도에 충족했는지 체크할 변수

      //   startsWith:문자열의 시작이 매개변수로 전달된 문자열로 시작하는지 체크

      const result: boolean = binary.startsWith(
        "0".repeat(generateBlock.difficulty)
      );
      console.log("result:", result);
      //   조건 충족 했으면 블록 채굴할 수 있는 권한을 얻었고 조건에 충족해서 나온 값을 반환
      if (result) {
        // 연산을 통해 완성된 hash 값과
        generateBlock.hash = hash;
        // 완성된 블록 내보내기
        return generateBlock;
      }
    }
  }

  static createBlockHash(_block: Block): string {
    const {
      version,
      timestamp,
      difficulty,
      height,
      merkleRoot,
      nonce,
      previousHash,
    } = _block;
    const value: string = `
    ${version},
    ${timestamp},
    ${difficulty},
    ${height},
    ${merkleRoot},
    ${nonce},
    ${previousHash},
    `;
    return SHA256(value).toString();
  }

  //   머클 루트 반환 구하는 함수
  static getMerkleRoot<T>(_data: T[]): string {
    const merkleRoot = merkle("sha256").sync(_data);
    return merkleRoot.root();
  }

  //   블록이 유효한지, 정상적인 블록인지 검사
  static isValidNewBlock(
    _newBlock: Block,
    _previousBlock: Block
  ): Failable<Block, string> {
    // 블록의 유효성 검사를 한다.

    // 블록의 높이가 정상적인지
    if (_previousBlock.height + 1 !== _newBlock.height) {
      return { isError: true, value: "이전 높이 오류" };
    }
    // 이전 블록의 해시값이 새로운 블록의 이전 해시값과 동일한지
    if (_previousBlock.hash !== _newBlock.previousHash) {
      return { isError: true, value: "이전 블록 해시 오류" };
    }

    // 생성된 블록의 정보를 가지고 다시 해시해서 블록의 값이 변조되었는지 정상적인 블록인지 학인
    if (Block.createBlockHash(_newBlock) !== _newBlock.hash) {
      return { isError: true, value: "블록 해시 오류" };
    }

    // 유효성 검사 통과
    return {
      isError: false,
      value: _newBlock,
    };
  }
}

export default Block;

이게뭐야
천천히 보자

1.생성자


constructor(_previousBlock: Block, _data: string[]) {
    // 부모 클래스 생성자 호출 super를 사용함
    super(_previousBlock);
    this.merkleRoot = Block.getMerkleRoot(_data);

    // 지금은 0으로
    this.nonce = 0;
    // 지금은 난이도 3
    this.difficulty = 3;
    this.data = _data;
    // 블록 본인의 데이터를 해시화 한게 블록의 해시값
    this.hash = Block.createBlockHash(this);
  }

=> super(_previousBlock); 를 사용하여 전 블록을 부모(헤더값)에 생성자로 주어 헤더를 생성하였다. 전 블록(body+header)을 전달하여 현재 블록의 header를 생성하는 것이 흥미롭다.

2.블록 추가

static generateBlock(_previousBlock: Block, _data: string[]): Block {
    const generateBlock = new Block(_previousBlock, _data);
    // 마이닝을 통해서 블록의 생성 권한을 받은 블록을 만들고
    const newBlock = Block.findBlock(generateBlock);
    return newBlock;
  }

블록을 생성하는 함수로, 반환되는 newBlock이 체인에 저장될 최종 블록이다!
그러기 위해선 이 함수 내에서 동적으로 현재 Block을 생성해 줘야한다!

이 말인 즉슨 Block 클래스를 밖에서 본격적으로 생성할 때
const temp=new Block(...)이렇게 객체를 생성하는 것이 아닌
const temp2=Block.generateBlock(...)이렇게 생성해서 새로 생성된 블록 값을 할당해줘야 한다는것이다. 왜냐하면 const temp=new Block(...) 구문이 이미generateBlock 함수안에 있기 때문이다.

3.findBlock

 static findBlock(generateBlock: Block) {
    let hash: string;
    // nonce 변수는 블록의 채굴을 하는데 연산을 몇번 진행했는지 값을 여기에 담을 것임
    let nonce: number = 0;

    while (true) {
      generateBlock.nonce = nonce;
      nonce++;
      //   블록 해시 구하는 구문 추가
      hash = Block.createBlockHash(generateBlock);

      //   16진수=>2진수로 변환해야한다.
      //   16진수를 2진수로 변환해서 0의 개수가 난이도의 개수에 충족하는지 체크
      // 블록 채굴의 권한을 받는다.
      // 성공시 while탈출=> 채굴 성공

      const binary: string = CryptoModule.hashToBinary(hash);
      // binary값 안에 있는 nonce를 증가시키기 때문에 => hash값이 바뀜 =>즉, binary값도 바뀌게 됨

      console.log("binary:", binary);
      //   연산의 값이 난이도에 충족했는지 체크할 변수

      //   startsWith:문자열의 시작이 매개변수로 전달된 문자열로 시작하는지 체크

      const result: boolean = binary.startsWith(
        "0".repeat(generateBlock.difficulty)
      );
      console.log("result:", result);
      //   조건 충족 했으면 블록 채굴할 수 있는 권한을 얻었고 조건에 충족해서 나온 값을 반환
      if (result) {
        // 연산을 통해 완성된 hash 값과
        generateBlock.hash = hash;
        // 완성된 블록 내보내기
        return generateBlock;
      }
    }
  }

채굴하는 함수이다! rtx3060ti 구조 작업 성공
기본적으로 generateBlock는 hash값(아직 채굴 전이므로)을 제외한 모든 값이 다 들어있는 상태고 createBlockHash함수를 통해 이 모든 정보를 버무려 답안 hash를 생성했다. 이 hash를 while문 안에서 돌려 2진수로 변환 한다음 0의 개수를 세어 난이도 이상이면 result를 true로 하여 채굴에 성공한다! 그렇게 채굴이 성공하면 해당 정답 hash를 현재 block의 hash에 할당하면 현재 블록이 완전히 완성된다!

while문에서 hsah값을 파별한다면 hash값이 계속 바뀐다는건데? =>그렇다! nonce값이 1오르기 때문이다! 고작 1 올랐다고 hash배열은 완전히 바뀌게 된다.

4.createBlockHash

static createBlockHash(_block: Block): string {
    const {
      version,
      timestamp,
      difficulty,
      height,
      merkleRoot,
      nonce,
      previousHash,
    } = _block;
    const value: string = `
    ${version},
    ${timestamp},
    ${difficulty},
    ${height},
    ${merkleRoot},
    ${nonce},
    ${previousHash},
    `;
    return SHA256(value).toString();
  }

현재 블록의hash를 제외한 나머지 정보 를 hash로 변환하는 함수다.

5.머클 루트 반환함수

static getMerkleRoot<T>(_data: T[]): string {
    const merkleRoot = merkle("sha256").sync(_data);
    return merkleRoot.root();
  }

머클 라이브러리를 이용해 머클 루트 값을 구한다.
간단하게 말해서 배열의 값들을 두 개씩 묶어 해쉬하고 나온 값들 중 또 두개를 묶고 해쉬하고.... 를 반복해서 최종 해쉬를 구하는 알고리즘이다.

6.테스트에서 사용할 판별 함수

static isValidNewBlock(
    _newBlock: Block,
    _previousBlock: Block
  ): Failable<Block, string> {
    // 블록의 유효성 검사를 한다.

    // 블록의 높이가 정상적인지
    if (_previousBlock.height + 1 !== _newBlock.height) {
      return { isError: true, value: "이전 높이 오류" };
    }
    // 이전 블록의 해시값이 새로운 블록의 이전 해시값과 동일한지
    if (_previousBlock.hash !== _newBlock.previousHash) {
      return { isError: true, value: "이전 블록 해시 오류" };
    }

    // 생성된 블록의 정보를 가지고 다시 해시해서 블록의 값이 변조되었는지 정상적인 블록인지 학인
    if (Block.createBlockHash(_newBlock) !== _newBlock.hash) {
      return { isError: true, value: "블록 해시 오류" };
    }

    // 유효성 검사 통과
    return {
      isError: false,
      value: _newBlock,
    };
  }

블록을 이전 블록과 비교하고,유효한 블록인지 판별하는 함수다

3.crypto

1.crypto.module.ts

class CryptoModule {
  static hashToBinary(hash: string): string {
    let binary: string = "";
    // 16진수를 2진수로 바꾸는 식
    // 해시 문자열을 2글자씩 가지고 와서 반복

    for (let i = 0; i < hash.length; i += 2) {
      // 반복문에서 i를 2씩 증가
      const hexByte = hash.substr(i, 2);
      //   16진수를 바이트
      const dec = parseInt(hexByte, 16);

      //   10진수를 2진 문자열로 반환 8자리로 패딩
      const binaryByte = dec.toString(2).padStart(8, "0"); // 변경된 부분: dec.toString(2)로 수정
      //   현재의 2진 바이트를 최종 이진 문자열에 추가
      binary += binaryByte;
    }
    return binary;
  }
}

export default CryptoModule;

string으로 들어온 16진수 hash를 2진수로 바꿔주는 함수이다.

4.test

1.jest.config.ts

import type { Config } from "@jest/types";

const config: Config.InitialOptions = {
  // 1.모듈 파일 확장자 설정: typescript와 javascript 둘 다 테스트 파일로 지정
  moduleFileExtensions: ["ts", "js"],
  //   2.테스트 파일 매치 설정: 파일의 이름의 패턴을 설정
  //  루트 경로에서 모든 폴더에 모든 파일 이름의 패턴이 test.js or test.ts
  testMatch: ["<rootDir>/**/*.test.(js|ts)"],
  // 3.모듈의 이름에 대한 별칭 설정:@core
  //   뱔칭으로 지정된 @core를 어떻게 경로를 바꿔줄거냐
  // ^@core==@core/**/* 시작하는 별칭은 루트 경로에 src/core의 경로까지

  moduleNameMapper: {
    // rootDir는 ts_lecture
    "^@core/(.*)$": "<rootDir>/20230904/02.Block/src/core/$1",
  },

  //   4.테스트 환경 설정:node환경에서 실행 시킬거임
  testEnvironment: "node",
  // 5.자세한 로그 설정 출력: 터미널에 로그들을 더 자세히 출력할지 여부
  verbose: true,
  // 6.프리셋 설정:typescript 에서 사용랄 jest/ts-jest설정
  preset: "ts-jest",
};
export default config;

2.tsconfig.json


{
  "compilerOptions": {
    "module": "CommonJS",
    "outDir": "./dist",
    "target":"ES6",
    "esModuleInterop": true,
    "baseUrl": ".",
    "paths": {
      // baseurl 경로 부털 별칭
      "@core/*":["20230904/02.Block/src/core/*"]
    }

  },
  "ts-node": {
    "require": ["tsconfig-paths/register"]
  }
}

"@core/":["20230904/02.Block/src/core/"] 이 부분은 새로운 수업을 나갈 때마다 항상 주의해서 경로를 바꿔주자

3.block.test.ts

// 테스트 코드를 작성하면 시간이 오래걸리지만
// 코드의 품질을 좀 더 올릴 수 있다.
//  단위별로 테스트를 진행해서 디버깅을 하고 코드를 작성할 수 있기 때문에

// 1단계 코드를 2단계 코드를 실행하고 설치적으로 테스트를 우리가 진행을 해볼수가 있다.

import Block from "@core/block/block";
import { GENESIS } from "@core/config";
//describe:테스트 들의 그룹화 그룹을 지정할 수 있다.
// 첫번째는 그룹의 명 이름, 어떤 테스트 그룹인지
// 두번째 매개변수로 테스트 들을 실행시키는 콜백함수
// describe("block 테스트 코드 그룹", () => {
// 하나의 테스트 단위, 첫번째 매개변수에는 테스트 이름 명
// 두번째 매개변수는 테스트의 도앚ㄱ을 가지고있는 콜백함수
//   it("제네시스 블록 테스트", () => {
//     console.log(GENESIS);
//   });
// });

describe("block검증", () => {
  let newBlock: Block;
  let newBlock2: Block;
  it("블록 추가", () => {
    const data = ["임시 페페1"];
    newBlock = Block.generateBlock(GENESIS, data);
    // 블록의 난이도에 따른 마이닝을 동작해서
    // 조건에 맞을 때 까지 연산을 반복한 뒤에 생성된 블록을 newBlock에 받아온다.
    // 이전 블록은 GENESIS
    console.log(newBlock);
    const data2 = ["임시 페페 2"];
    newBlock2 = Block.generateBlock(newBlock, data2);
    console.log(newBlock2);
  });

  it("블록 유효성 검증", () => {
    const isValidBlock = Block.isValidNewBlock(newBlock, GENESIS);
    if (isValidBlock.isError) {
      // expect: toBe:값이 맞는지 확인할 때
      // 성공한 결과가 맞는지 확인할 때 사용하는 코드
      // true false비교해서 맞는지 확인
      return expect(true).toBe(false);
    }
    expect(isValidBlock.isError).toBe(false);
  });
});

genesis

// 제네시스 블록
// 최초 블록은 하드 코딩

import { IBlock } from "./interface/block.interface";

export const GENESIS: IBlock = {
  version: "1.0.0",
  height: 0,
  timestamp: new Date().getTime(),
  hash: "0".repeat(64),
  previousHash: "0".repeat(64),
  merkleRoot: "0".repeat(64),
  // 블록을 채굴할 때 이전 블록 난이도로 마이닝을 한다.
  // 블록의 생성 주기를 검사를 해서 생성주기가 빠르면 블록에 난이도를 상승시키고
  // 블록의 생성 주기가 느리면 블록의 난이도를 하락시킨다.
  difficulty: 0,
  nonce: 0,
  data: ["페페 더 스타트 오브 레전드"],
};

최초 블록을 기준으로 test를 진행하였다.

it("블록 추가", () => {
    const data = ["임시 페페1"];
    newBlock = Block.generateBlock(GENESIS, data);
    // 블록의 난이도에 따른 마이닝을 동작해서
    // 조건에 맞을 때 까지 연산을 반복한 뒤에 생성된 블록을 newBlock에 받아온다.
    // 이전 블록은 GENESIS
    console.log(newBlock);
    const data2 = ["임시 페페 2"];
    newBlock2 = Block.generateBlock(newBlock, data2);
    console.log(newBlock2);
  });

앞서 말한대로 객체생성을 new가 아닌Block.generateBlock로 블록을 생성함으로서 값을 할당함을 알 수 있다.

4.마지막으로 설치 순서

npm init -y
npm i -D typescript ts-node
npm i -D @types/merkle merkle
npm i -D @types/crypto-js crypto-js

#  tsconfig-paths ts-node로 node 환경에서 실행을 할 때 우리가 정해준 별칭을 경로로 변환해서 실행시키기 위해 사용
npm i -D tsc-alias tsconfig-paths


npx tsc --init


# zert 라이브러리 설치
npm i -D @types/jest jest

jest.config.ts

  • jest로 test code를 실행할 때 옵션설정 파일

3.느낀점

객체 지향이 마구마구 나오고 있다. 자바스크립트로 이렇게 객체지향개념을 쓴건 처음인 것 같다. 이로서 개발자는 여러패턴과 코딩 아키텍쳐를 알아야함을 몸소 깨달았다. 리액트만 한다고 함수지향만 공부하다가는 분명히 벽을 느낄 것이다. 사실 자바를 배웠을 때 객체지향 개념이 재밌었던 기억이 있다 ㅎㅎㅎ
그 느낌을 그대로 살려 스마트 컨트랙틑 배포하는 날까지 객체지향과 블록체인의 기본 개념을 탄탄히 잡을 것이다.

profile
개발당시에 직면한 이슈를 정리하는 곳

0개의 댓글