Thresholded Proof of Stake (TPoS)

오동재·2022년 11월 14일
post-thumbnail

NEAR에서 사용하는 합의알고리즘, TPoS에 대해 알아보고 NEAR localnet을 구축하여 이를 테스트해보자.

🤝 Thresholded Proof of Stake

임계값 지분 증명
PoS는 익숙하다. DPoS도 잘은 모르지만 들어는 봤다. 뭐 위임 지분 증명 어쩌구
근데 TPoS는 뭘까. 찾아보려했는데 생각보다 자료가 많이 없다. 특히 한글자료는 더더욱 없다.

📌 요약

간단한 내용은 아래와 같다. 잘 이해가 되지 않는다면 글과 reference를 정독해보길 바란다.

NEAR에서 사용하는 용어

witness : NEAR에서 validator를 지칭하는 말
epoch : witness들이 유효한 기간. 기본값은 24시간임(정확히는 블록 개수로 측정됨)
slot : 1분에 한 slot씩 존재하며 한 epoch에는 1440slot이 존재
seat : slot안에 배치된 각 witness들의 자리. 한 slot에 1024seat이 있음

witness 선정과정 및 블록 생성과정

  • 1분의 한 slot씩 총 1440slot이 존재하고 한 slot에 1024seat씩 존재한다.
  • 1440*1024 = 1,474,560 seat이 존재하고 각 seat들은 고유번호를 가진다.
  • 스테이킹된 총량을 1,474,560으로 나눈 값이 seat 하나의 가격이 되고 이 임계값 이상의 지분을 스테이킹한 홀더들은 랜덤으로 seat을 분배받는다.
  • 할당받은 후에는 각 slot의 seat을 차지한 witness들이 PoS로 블록을 생성한다.
  • 하루(epoch)가 지나면 witness를 새로 선정한다.

👪 TPoS

NEAR는 Thresholded Proof of Stake(TPoS)이라는 합의 알고리즘을 사용한다. 이는 네트워크 유지보수를 유지하는 다수의 참여자를 확보하여 분산, 보안을 강화하고 공정한 보상 분배를 확립하는 결정론적 방법을 원한다는 것이다. NEAR의 방법에 대한 가장 가까운 대안은 사람들이 고정된 수의 항목에 입찰하고 결국 상위 N개의 입찰가가 낙찰되는 동시에 입찰 규모에 비례하는 수의 항목을 받는 경매이다.

NEAR는 특정 시간 간격(기본값은 하루) 동안 결정을 내리기 위해 많은 참가자 풀(witnesses이라고 부른다)이 선출되기를 원한다. 각 간격은 각 블록당 상당히 많은 수의 witness(기본값은 1024)과 함께 많은 수의 블록 slot(기본적으로 1440개 슬롯, 1분에 하나씩)으로 분할됩니다. 이러한 기본값으로 인해 1,474,560명의 개별 witness seats을 채워야 한다.

witness seat은 signing blocks(서명하는 블록)이 되기를 원하는 모든 참가자의 지분에 의해 정의된다. 예를 들어, 1,474,560명의 참가자가 각각 10개의 토큰을 보유한다면, 각 개별 seat의 가치는 10개의 토큰이 되고 각 참가자는 1개의 좌석을 갖게 된다. 또는 10명의 참가자가 각각 1,474,560개의 토큰을 스테이킹할 경우, 개인 좌석은 여전히 10개의 토큰이 필요하며 각 참가자에게는 14만7456개의 seat이 주어진다.
아래에 식에서, Xseat 가격이고 Wi가 각 참가자의 지분이다.

네트워크 유지 보수에 참여하기 위해(witness가 되기 위해) 어떤 계좌든 자신이 얼마나 많은 돈을 스테이킹하고 싶어하는지 나타내는 특별 Tx를 제출할 수 있다. 해당 Tx가 승인되는 즉시 지정된 금액의 돈이 최소 3일 동안 lock되어 있습니다. 결국, 새로운 witness 제안은 모두 하루 동안 블록에 서명한 모든 참가자들과 함께 수집됩니다. 거기서, 우리는 (위의 공식으로) 개별 seat에 대한 비용을 식별하고, 최소 해당 비용을 스테이킹한 모든 사람들에게 랜덤으로 seat을 할당해준다.

NEAR 프로토콜은 인플레이션 블록 보상과 트랜잭션 수수료를 사용하여 witness들이 Signing block에 참여하도록 장려한다. 구체적으로, NEAR는 인플레이션율이 총 토큰 수에 대한 백분율로 정의될 것을 제안한다. 이것은 홀더가 네트워크 가치에서 그들의 몫을 유지하기 위해 참여 노드를 실행하도록 장려한다.

참가자가 witness로 등록하면, 그들의 돈의 일부가 lock되어서 사용할 수 없다. 각 witness지분은 witness가 블록서명 참여를 중단한 다음 날 해제된다. 이 시간 동안 증인이 두 개의 경쟁 블록에 서명하면, 그들의 지분은 "이중 서명"을 알아차리고 증명한 참가자에게 몰수됩니다.

witness를 선출하기 위한 이 메커니즘의 주요 장점은 다음과 같다.

  • No pooling necessary. 보상이 지분에 정비례하기 때문에 지분이나 계산 자원을 모을 이유가 없다. 즉, 각각 10개의 토큰을 보유한 두 개의 계정이 하나의 계정에 20개의 토큰과 동일한 수익률을 제공하게 된다. 토큰 수가 threshold보다 적을 경우가 유일한 예외로, 매우 많은 수의 선택된 witness에 의해 상쇄된다.
  • Less Forking. 포크는 심각한 네트워크 분할이 있을 때만 발생한다(악성 노드가 ⅓ 미만인 경우). 정상 동작에서 사용자는 블록에 있는 시그니처의 수를 관찰할 수 있으며, 이것이 ⅔+1 이상이면 블록은 비가역적이다. 네트워크 분할의 경우, 참여자들은 얼마나 많은 서명이 있는지와 얼마나 많은 서명이 있어야 하는지를 이해함으로써 분할을 명확하게 관찰할 수 있다. 예를 들어, 네트워크가 분할되는 경우, 대다수의 네트워크 참여자들은 필요한 시그니처가 ⅔(그러나 아마도 ½ 이상) 미만인 블록을 관찰하고 과거의 블록이 역전될 가능성이 없다고 결론 내리기 전에 충분한 수의 블록을 기다리도록 선택할 수 있다. 소수의 네트워크 참여자들은 ½만큼의 시그니처보다 작은 블록들을 보게 될 것이고, 네트워크 분할이 유효할 수 있다는 명확한 증거를 갖게 될 것이며, 그들의 블록들이 덮어쓰기 될 가능성이 높고 최종적인 목적을 위해 사용되어서는 안 된다는 것을 알게 될 것이다.
  • Security. 단일 블록을 다시 작성하거나 장거리 공격을 수행하는 것은 과거 이틀 동안 총 지분의 3분의 2이상을 보유한 증인으로부터 개인 키를 받아야 하기 때문에 매우 어렵다. 이는 각 증인이 하루만 참여했다고 가정한 것으로, 계좌에 토큰을 보유할 경우 지속적으로 참여하겠다는 경제적 유인으로 인해 거의 발생하지 않을 것이다.

🛠️ 실습

로컬넷을 구축해서 테스트해보자.
테스트넷도 해보려했지만 rpc node도 스토리지가 너무 많이 필요해서 포기했다.

1. 세팅

아래 링크를 참조해 1. Clone nearcore project from GitHub, 2. Compile nearcore binary, 3. Initialize working directory까지 진행한다.
https://near-nodes.io/validator/compile-and-run-a-node#localnet

3. Initialize working directory을 실행한 후 ~/.near경로를 확인해보면 config.json, genesis.json 등 파일이 생성된 것을 확인할 수 있다.
이번 실습에서는 이를 세 번 실행해 세 개의 노드를 연결하는 작업을 해보겠다.

2. validators 노드 연결

세 개의 validator를 사용하기 위해 genesis.jsonvalidators, total_supply, records를 아래와 같이 바꿔주었다.
public_key같은 경우는 자신이 생성한 노드들의 validator_key.json파일을 참고하여 넣어주자.

// genesis.json
{
"validators": [
    {
      "account_id": "node0",
      "public_key": "ed25519:7WLXYVJaZpDJgEuNqmaGN2nCosdT1pynTyHS5PJWgEgv",
      "amount": "40000000000000000"
    },
    {
      "account_id": "node1",
      "public_key": "ed25519:BWf7NEHUrihCQW8ZTYZXwnDjiTp3d47qGzPffG3GRx71",
      "amount": "9000000000000000"
    },
    {
      "account_id": "node2",
      "public_key": "ed25519:4yogaVJz46XRKFhx158qvoXRb2dwXja76dmxVFbFBkHc",
      "amount": "10000000000000000"
    }
  ]
  ...중략...
"total_supply": "181988000046005328250000000000000",
  ...중략...
"records": [
    {
      "Account": {
        "account_id": "node0",
        "account": {
          "amount": "181988000046005231250000000000000",
          "locked": "40000000000000000",
          "code_hash": "11111111111111111111111111111111",
          "storage_usage": 0,
          "version": "V1"
        }
      }
    },
    {
      "AccessKey": {
        "account_id": "node0",
        "public_key": "ed25519:7WLXYVJaZpDJgEuNqmaGN2nCosdT1pynTyHS5PJWgEgv",
        "access_key": {
          "nonce": 0,
          "permission": "FullAccess"
        }
      }
    },
    {
      "Account": {
        "account_id": "node1",
        "account": {
          "amount": "18000000000000000",
          "locked": "9000000000000000",
          "code_hash": "11111111111111111111111111111111",
          "storage_usage": 182,
          "version": "V1"
        }
      }
    },
    {
      "AccessKey": {
        "account_id": "node1",
        "public_key": "ed25519:BWf7NEHUrihCQW8ZTYZXwnDjiTp3d47qGzPffG3GRx71",
        "access_key": {
          "nonce": 0,
          "permission": "FullAccess"
        }
      }
    },
    {
      "Account": {
        "account_id": "node2",
        "account": {
          "amount": "20000000000000000",
          "locked": "10000000000000000",
          "code_hash": "11111111111111111111111111111111",
          "storage_usage": 0,
          "version": "V1"
        }
      }
    },
    {
      "AccessKey": {
        "account_id": "node2",
        "public_key": "ed25519:4yogaVJz46XRKFhx158qvoXRb2dwXja76dmxVFbFBkHc",
        "access_key": {
          "nonce": 0,
          "permission": "FullAccess"
        }
      }
    }
  ]
}

그리고 genesis.json과 같은 경로에 있는 validator_key.jsonaccount_idpublic_keygenesis.json과 통일시켜주자.

그리고 각 node들의 config.json에서 rpc.addrnetwork.addr은 모두 달라야 포트 충돌없이 정상적으로 실행될 것이다. 사실 rpc.addr은 포트 3030에서만 정상작동하면 된다. 자신이 좋아하는 대로 임의로 바꿔주자.
config.jsonnetwork.boot_nodesed25519:<publicKey>@<addr>,~~형식으로 바꿔주자.

// node0/config.json
{
  "genesis_file": "genesis.json",
  "genesis_records_file": null,
  "validator_key_file": "validator_key.json",
  "rpc": {
    "addr": "0.0.0.0:3030", //이 부분이 겹치면 당연히 포트가 충돌난다. 
   ...중략...
  "network": {
    "addr": "0.0.0.0:24567", // 이 부분도 겹치면 당연히 포트가 충돌되서 실행이 안된다.
    "external_address": "",
    // boot_nodes의 대한 예시는 아래를 참고하자.
    "boot_nodes": "ed25519:7WLXYVJaZpDJgEuNqmaGN2nCosdT1pynTyHS5PJWgEgv@0.0.0.0:24567,ed25519:BWf7NEHUrihCQW8ZTYZXwnDjiTp3d47qGzPffG3GRx71@0.0.0.0:24568,ed25519:4yogaVJz46XRKFhx158qvoXRb2dwXja76dmxVFbFBkHc@0.0.0.0:24569",
    "max_num_peers": 40,
    "peer_recent_time_window": {
      "secs": 600,
      "nanos": 0
    },
  ...생략...

네트워크 실행
네트워크가 처음 실행될 때는 한 노드의 지분이 전체 지분의 2/3를 초과해야 블록이 생성되기 시작한다. 그렇기 때문에 지분 비율을 node0:node1:node1 = 40:9:10 으로 설정하여 node0의 지분이 2/3를 초과하도록 해줬다.
실행조건일 뿐이고 한번 실행된 다음에는 지분 비율이 이를 만족하지 않아도 상관없다.
실행 명령어는 ./target/release/neard --home <node pwd> run이다.
<node pwd>에 실행하려는 노드 config파일이 있는 경로를 적어주면 된다.

node1과 node2만 실행시켰을 때
아래 사진은 node1과 node2만 실행시킨 경우이다. 네트워크의 첫 시작이기때문에 지분이 2/3를 초과하는 메인 노드인 node0이 실행되지 않아 블록을 생성하지 못한다.

node0을 실행시켰을 때
블록이 생성되기 시작하는 것을 확인할 수 있다. node1이 block@5를, node2는 block@3block@7을, 그리고 지분이 가장많은 node0이 나머지 블록들을 생성한 것을 확인할 수 있다.

near cli로 현재 상태 확인
터미널에서 near cli를 사용하기 위해 npm install -g near-cli로 near cli를 설치해주자.
export NEAR_ENV=localnet를 통해 환경변수를 설정해주고
near validators current를 사용해 현재 에폭에서의 witness들 상태를 확인할 수 있다.

3. stake 해보기

터미널에서 near cli를 사용하기 위해 npm install -g near-cli로 near cli를 설치해주자.

그리고 ~/.near 경로에 validator_key.json이라는 이름으로 아래와 같은 파일을 생성해주자.

{
  "account_id": "node0",
  "public_key": "ed25519:7WLXYVJaZpDJgEuNqmaGN2nCosdT1pynTyHS5PJWgEgv",
  "secret_key": "ed25519:RkMHiKJKVoyE4sFzzVstC7Hb8Wj9J976VduymVfXmSkHbN71cf4ozrxWEtyPJRsTwmS81K6vfPhbGzKj2jNt4HL"
}

epoch이 너무 길면 witness가 바뀌는 데에 너무 많은 시간을 기다려야하므로 genesis.json에서 epoch을 수정해주자. 너무 짧으면 관찰이 힘들다. 이것저것 해보면서 테스트해보고 싶으면 100정도가 테스트하기 적당한 것 같다.
노드 3개를 실행해주고 관찰해보자

## node 실행
$ ./target/release/neard --home ~/.near/localet/node0 run
$ ./target/release/neard --home ~/.near/localet/node1 run
$ ./target/release/neard --home ~/.near/localet/node2 run

export NEAR_ENV=localnet으로 환경변수를 설정해주고 epoch마다 validator를 관찰해보자

🗓️ epoch1 ( block@1 ~ block@100 )

네트워크가 막 시작된 후에는 stake가 0으로 표기된다. 단순 버그인지 시스템이 이런건지는 잘 모르겠다. 하지만 만약 실제로 이를 프라이빗 네트워크로 사용한다고 하면 시작점은 크게 중요하지 않으니 실사용에 지장은 없을 듯 하다.
표기는 stake 0이지만 블록 생성량은 genesis.json에서 기입한 지분과 비례한다.

🗓️ epoch2 ( block@101 ~ block@200 )

두번째 epoch까지도 stake가 0으로 표기된다. 하지만 이때부터는 다음 witness들의 Stake가 정상적으로 표기되는 것을 확인할 수 있다.

🗓️ epoch3 ( block@201 ~ block@300 )

세번째 epoch부터는 stake가 정상적으로 표시된다.
next validators를 확인해보면 모든 witness들의 지분이 기존 지분에 비례하는 수치만큼씩 증가하는 것을 확인할 수 있다.
node0은 14,268을 스테이킹하여 지분이 11만큼 증가하고
node1은 3,210을 스테이킹하여 지분이 3 증가하고
node2는 3,567만큼 스테이킹하여 지분이 3증가한다.

🗓️ epoch4 ( block@301 ~ block@400 )

epoch4또한 예상대로 epoch3에서 확인했던 next validators의 stake를 확인할 수 있다.
이번엔 node0의 stake를 변경해보겠다.
near stake <account_id> <public_key> <amount>
명령어를 사용해 node0의 stake를 14,000에서 3,000으로 줄여보자.

stake를 변경한 후 near validators currnetnear validators next를 실행했지만 변한 게 없다.
확인할 수 있는 것은 증가한 블록의 양 뿐이다.

엥, 뭔가 잘못됐나?

🗓️ epoch5 ( block@401 ~ block@500 )

잘못되지 않았다.

다음 epoch에서 next validators를 확인해보면 알 수 있다.
epoch4에서 지분을 변경하는 트랜잭션을 생성하면 epoch6에서 실질적인 지분이 변하는 것이다. 위에서 TPoS에 대해 설명하면서 NEAR는 한 번 스테이킹하면 3일동안 지분이 묶여있는다고 설명했었는데, 그 로직이 이를 말하는 게 아닐까싶다.
즉, epoch(n)에서 staking하면 epochp(n+2)에서 적용된다.

🗓️ epoch6 ( block@501 ~ block@600 )

epoch4에서 변경한 지분이 잘 적용된 것을 확인할 수 있고 이에 따른 블록 생성량의 비율도 변한 것을 확인할 수 있다.

Reference

https://near.org/papers/the-official-near-white-paper
https://near.org/blog/thresholded-proof-of-stake/
https://docs.rs/near-chain-configs/0.1.0/near_chain_configs/struct.ProtocolConfigView.html
https://nomicon.io/

profile
https://donggni0712.tistory.com 로 이사했습니다~

0개의 댓글