옵티미즘 사용하기 : staking service on Layer2

Harvey Jo
Tokamak Network
Published in
21 min readJul 19, 2021

--

들어가며

이 글은 옵티미즘을 사용하여서 레이어1에 있는 토큰을 레이어2로 이동한 뒤 스테이킹하고 스테이킹에 대한 보상을 받는 컨트랙트 분석과 테스트를 통해서 확인하며 스테이킹 시나리오에 따라 스테이킹이 정확하게 되고 있는지 확인해보겠습니다.

이 글에선 따로 옵티미즘의 기본개념을 설명하지 않습니다. 옵티미스틱 롤업에 대한 내용을 이해하시고 싶은 분은 링크을 통해서 내용을 이해할 수 있습니다.

초기 옵티미즘 환경 세팅

옵티미즘을 사용한 레이어2를 사용하기 위해서는 레이어1과 레이어2가 서로 통신할 수 있는 환경이 필요합니다. 이러한 환경을 구성하기 위해서는 많은 서비스들이 실행되어야 합니다. 이러한 서비스들을 쉽게 관리하고 실행할 수 있도록 옵티미즘 저장소에서는 도커를 사용하여 환경을 구성하고 있습니다.

옵티미즘의 도커 환경이 세부적으로 어떻게 구성되어 있고 각 서비스들이 하는 역할이 무엇인지 알고 싶으신 분들은 링크를 통해서 알 수 있습니다.

옵티미즘 도커 환경을 구성하고 테스트를 돌려보는 명령어를 보겠습니다.

옵티미즘 환경 구성 및 테스트

$ git clone https://github.com/ethereum-optimism/optimism.git  
$ cd optimism
$ yarn && yarn build
$ yarn test

Docker 환경 세팅 및 실행

$ cd ops
$ export COMPOSE_DOCKER_CLI_BUILD=1
$ export DOCKER_BUILDKIT=1
$ docker-compose build
$ docker-compose up -d

위의 명령어까지 입력하시면 옵티미즘 도커 환경 실행은 완료되었습니다. 현재 도커 컨테이너 실행이 제대로 되고 있는지 확인하기 위해선

$ docker-compose ps

를 통해서 상태를 확인할 수 있고

위와 같이 ops_builder_1을 제외한 다른 State 상태들이 Up이면 제대로 환경이 실행된 것 입니다.

builder는 저장소 의 패키지들을 빌드하는 용도로 사용하는 것으로 초기 환경구성에 실행되고 환경 구성 후 옵티미즘 운영 서비스에선 실행이 되지 않아서 State가 Exit 0 입니다.

옵티미즘 컨트랙트 환경 구성

$ cd ../
$ git clone https://github.com/Onther-Tech/optimism_staking_example.git
$ cd optimism_staking_example
$ yarn && yarn compile

레이어1과 레이어2의 ERC20 토큰 이동과 레이어2에서 ERC20 토큰을 스테이킹 하는 기능이 개발된 저장소를 받고 테스트 하기 위해서 컨트랙트 등을 빌드합니다.

$ npx hardhat test

테스트 실행시키고 정상적으로 실행이 완료가 되면 다음과 같은 결과를 확인할 수 있습니다.

Layer2 Staking
staking test1
Deploying L1 ERC20...
Deploying L2 ERC20...
Deploying L2 Staking...
Deploying L1 ERC20 Gateway...
--------------------------------
basic setting on Layer2
Balance on L2: 3000
Balance on L2_2: 3000
Balance on L2_3: 3000
Balance on L2_staking: 40000
--------------------------------
✓ approve ton to stakingContract (175ms)
✓ approve and deposit test (403ms)
✓ calculate pendingAmount (30052ms)
✓ one user deposit and withdraw (30982ms)
staking test2
✓ wallet1, wallet2 Deposit and Withdraw (81338ms)
staking test3
✓ wallet1, wallet2, wallet3 Deposit, Withdraw and Claim (152002ms)
6 passing (5m)

테스트는 크게 보면 staking test1, staking test2, staking test3로 나누어져 있는데, 아래에서 각각의 테스트 시나리오를 알아보겠습니다.

테스트 시나리오 및 테스트 코드

스테이킹에서 중요한 점은 유저가 스테이킹 한 시점에서부터 보상을 정말 공평하게 받을 수 있는가가 유저 입장에서 중요하다고 생각하고 컨트랙트를 개발할 때 이 부분을 중점으로 개발을 하였습니다. 다음은 보상을 공평하게 받는 지 테스트 시나리오를 통해서 알아보도록 하겠습니다.

테스트 초기 세팅

블록당 스테이킹 보상 = 30, staking contract amount = 40,000
A amount = 3,000 , B amount = 3,000 , C amount = 3,000
deposit amount = 100, withdraw amount = 100

1. A와 B가 스테이킹하며 A가 입금 후 B가 입금하고 B가 A보다 먼저 출금한다.

이 경우 이론적으로 A와 B가 받아야하는 reward를 계산해보면 다음과 같습니다.

A = [(7–5) + (3–1)] * [30 * (100/100)] + (5–3) * [30 * (100/200)] = 150
B = (5–3) * [30 * (100/200)] = 30
총보상 = (7–1) * 30 = 180 으로 총보상 = A + B 인 것을 확인할 수 있습니다.

실제로 이렇게 보상을 받을 수 있는지 시나리오1 테스트를 통해서 확인하겠습니다.

A가 100만큼의 토큰을 deposit하면 staking contract amount = 40,000에서 100이 증가되어 40,100이된 것을 확인하여 deposit을 확인하였습니다.

B가 100만큼의 토큰을 deposit한 블록이 inputNumber이고 100만큼의 토큰을 withdraw한 블록이 nowNumber이면 B가 받아야하는 reward는 calculReward가 됩니다. B가 보상을 제대로 받았는지 확인하기 위해서 calculReward가 B가 withdraw 후 가지고 있는 금액- deposit 전 초기금액과 같은지 확인 합니다.

컨트랙트에 pendingTon이라는 function은 유저가 현재 블록에서 reward를 얼만큼 받을 수 있는지 값을 return 해줍니다.
이를 이용하여서 A는 100만큼 토큰을 withdraw한 후 받은 reward가 pendingTon하여서 받은 reward와 같은지 확인합니다.

시나리오1에서는 A유저는 pendingTon을 활용하여 reward를 계산하였지만 시나리오2,3에서는 정확한 Reward 계산을 위해서 이론적 계산방법을 이용하여 Reward가 맞는지 확인하겠습니다.

2. A와 B가 스테이킹하며 A가 입금 후 B가 입금하고 A가 B보다 먼저 출금한다.

A = (3–1) * [30 * (100/100)] + (5–3) * [30 * (100/200)] = 90
B = (5–3) * [30 * (100/200)] + (7–5) * [30 * (100/100)] = 90
총보상 = (7–1) * 30 = 180 으로 총보상 = A + B 인 것을 확인할 수 있습니다.

A와 B는 100만큼의 토큰을 deposit하고 deposit이 되었는지 staking contract amount를 통해 확인하고 deposit했을때의 blocknumber를 기록합니다.

A와 B는 100만큼의 토큰을 withdraw하고 reward가 제대로 들어왔는지 확인하는 테스트 코드입니다.

A를 withdraw했을때 A의 reward는 diffblock1_1과 diffblock1_2로 구간이 나누어서 보상이 계산이 됩니다.
diffblock1_1은 B가 deposit하고 A가 withdraw할때까지의 기간으로 A와 B가 reward를 나누어 받는 구간입니다.
그래서 이때의 reward 값은 diffblock1_1 * tokenPerBlock * (100/200)이 됩니다.
diffblock1_2는 A가 deposit하고 B가 deposit할때까지의 기간으로 A가 혼자reward를 받는 구간입니다.
그래서 이때의 reward 값은 diffblock1_2 * tokenPerBlock * (100/100) 입니다.
각 구간의 reward의 합이 A가 받은 reward의 양과 같은지 확인합니다.

B를 withdraw했을때도 A와 마찬가지로 diffblock2_1과 diffblock2_2로 구간을 나누어서 보상을 계산할 수 있습니다.
diffblock2_1은 A가 withdraw하고 B가 withdraw할때까지의 기간으로 B 혼자 reward를 받는 구간입니다.
diffblock2_2는 B가 deposit하고 A가 withdraw할때까지의 기간으로 A와 B 같이 reward를 받는 구간입니다.
구간별로 reward를 계산하여서 각 구간의 reward의 합이 B가 받은 reward의 양과 같은지 확인합니다.

3. A와 B와 C가 스테이킹하며 A가 입금 후 B가 입금하고 B가 claim 후 C가 입금 하며 B, C, A 순으로 출금한다.

A = (2–1) * [30 * (100/100)] + (4–2) * [30 * (100/200)] + (5–4) * [30 * (100/300)] + (6–5) * [30 * (100/200)] + (7–6) * [30 * (100/100)] = 115
B = (4–2) * [30 * (100/200)] + (5–4) * [30 * (100/300)] = 40
C = (5–4) * [30 * (100/300)] + (6–5) * [30 * (100/200)] = 25
총보상 = (7–1) * 30 = 180 으로 총보상 = A + B + C 인 것을 확인할 수 있습니다.

A와 B는 토큰을 deposit하고 deposit이 되었는지 staking contract amount를 통해 확인하고 deposit했을때의 blocknumber를 기록합니다.
deposit 후 B는 claim을 하고 B가 deposit 했을 때와 claim 했을 때의 blocknumber를 이용하여서 reward를 계산하고 B가 reward를 제대로 받았는지 확인합니다.

C는 토큰을 deposit하여 제대로 스테이킹 되었는지 확인하고, B는 토큰을 withdraw하여서 reward가 제대로 들어왔는지 확인하는 테스트 코드입니다.

토큰이 deposit이 되었는지 staking contract amount를 통해 확인하는데 앞에서 B가 claim을 통해서 reward를 받아가서 reward를 더한 값과 비교하여 deposit을 확인합니다.

B는 중간에 claim을 하여서 deposit에서 claim 시점까지의 스테이킹에 대한 reward는 이미 받았지만 이것은 컨트랙트 내에서 작동하여서 처리를 해주고 테스트에서는 B가 최종적으로 스테이킹에 대한 reward을 제대로 받았는지를 테스트 하여 제대로 작동하는지 확인합니다.

B가 받는 reward의 구간은 2가지로 나눌 수 있습니다.
B가 deposit하고 C가 deposit하기까지의 구간(B1)과 C가 deposit하고 B가 withdraw하기까지의 구간(B2)입니다. (편의상 B1, B2로 부르겠습니다.)

여기서 B와 C는 같은 양의 토큰을 deposit 하였기 때문에 B2구간의 reward는 C가 pendingTon을 호출한 값과 같은 값을 가지게 됩니다.
그래서 C의 pendingReward값과 B2구간의 reward가 같은 값을 가지는지 확인합니다.
B1구간의 reward와 B2구간의 reward를 이용해 B가 받은 총 reward를 계산하고 B가 총 reward를 받았는지 확인합니다.

A와 C는 토큰을 withdraw하고 reward가 제대로 들어왔는지 확인하는 테스트 코드입니다.

먼저 C가 받는 reward의 구간은 2가지로 나눌 수 있습니다.
C가 deposit하고 B가 withdraw하기까지의 구간(C1)과 B가 withdraw하고 C가 withdraw하기까지의 구간(C2)입니다. (편의상 C1, C2로 부르겠습니다.)

C1구간은 B2의 구간과 같기 때문에 C1의 reward는 B2의 reward와 같고 이 값은 위의 테스트 에서 구하였습니다.
C2구간의 reward를 계산해주고 C1구간과 C2구간의 reward를 합하여 C가 받아야하는 총 reward 값을 계산해주고 C가 받은 reward 값이 같은지 확인합니다.

다음 A가 받는 reward의 구간은 5가지로 나눌 수 있습니다.
A1 : A가 deposit하고 B가 deposit하기까지의 구간
A2 : B가 deposit하고 C가 deposit하기까지의 구간
A3 : C가 deposit하고 B가 withdraw하기까지의 구간
A4 : B가 withdraw하고 C가 withdraw하기까지의 구간
A5 : C가 withdraw하고 A가 withdraw하기까지의 구간

A2구간은 B1구간과 같고, A3구간은 B2구간과 같고, A4구간은 C2구간과 같기때문에 A2, A3, A4구간에서의 reward값은 위의 테스트에서 구하였습니다.
그래서 A1과 A5의 구간에서의 reward 값을 계산해주고 각 구간의 reward를 합하여 A가 받아야하는 총 reward 값과 A가 받은 reward 값이 같은지 확인합니다.

그리고 최종적으로 A reward, B reward와 C reward를 합하여 스테이킹으로 준 reward의 합을 구하고 스테이킹 컨트랙트의 줄어든 토큰의 양이 같은지 확인하여 reward를 제대로 주었는지 확인합니다.

시나리오 정리

1번 시나리오 : A유저가 스테이킹을 하고 있을 때 B유저가 스테이킹을 하였다가 출금했을때 보상이 제대로 주어지는지에 대한 테스트 입니다.

2번 시나리오 : A유저가 스테이킹을 하고, B유저가 스테이킹을 한뒤, A유저가 먼저 출금을 하고 B유저가 출금을 하였을때 보상이 제대로 주어지는지에 대한 테스트 입니다.

3번 시나리오 : 다수가 스테이킹을 하였을때도 보상이 제대로 주어지는지와 스테이킹 중간에 보상만 받을 수 있는 claim 기능이 제대로 작동하는지에 대한 테스트 입니다.

Staking 컨트랙트 코드 분석

UserInfo에서 user마다 amount와 rewardDebt을 따로 저장하여 관리하여서 해당 유저가 얼만큼 스테이킹하였는지 알 수 있으며, rewardDebt을 활용하여서 공평하게 보상을 나누어 줄 수 있고 스테이킹 기간 중 claim이 가능하게 합니다.

ton은 스테이킹할 토큰을 지정합니다.

tonPerShare라는 값은 이번 스테이킹 로직에서 가장 중요한 변수입니다. deposit()과 withdraw()시 updateReward()를 호출하고 여기서 tonPerShare을 지금까지 쌓인 보상과 총 스테이킹된 양을 활용하여서 tonPerShare을 업데이트해 주고 tonPerShare을 이용하여서 user의 rewardDebt을 계산합니다.

lastRewardBlock은 deposit이나 withdraw를 한 최근 blockNumber를 저장합니다.

totalAmount는 스테이킹된 총 token의 양입니다.

tokenPerBlock은 block마다 토큰을 얼만큼 주는지 정해주는 변수입니다. (tokenPerBlock은 초기 컨트랙트 배포 시 지정하며 변경할 수 없습니다.)

getBlockPeriod()는 블록이 지난번 update됬을때 보다 얼만큼 지났는지 return해줍니다.

updateReward()는 deposit()과 withdraw()시 호출되며 tonPerShare와 lastRewardBlock을 업데이트해줍니다.

deposit()은 만약 이미 입금한 유저면 유저한테 지금까지 쌓인 보상을 주고 유저의 amount와 rewardDebt을 업데이트 해주고 totalAmount도 업데이트 해줍니다.

withdraw()는 유저가 입금한 금액이 withdraw할려는 amount이상 있는지 확인하고 확인 후 유저한테 보상과 withdraw할려는 amount를 주고 유저의 amount와 rewardDebt, totalAmount를 업데이트합니다.

claim()은 유저가 deposit을 하였는지 확인하고 deposit을 하였으면 지금까지 쌓인 보상을 계산 후 보상을 주고 유저의 rewardDebt에 준 보상을 추가해주어서 최종적으로 withdraw시 claim에서 준 보상을 제외하고 주게 합니다.

pendingTon()은 유저의 보상이 얼만큼 받을 수 있는지를 return 해줍니다.

safeTonTransfer()는 보상을 줄 때 컨트랙트에서 줄 수 있는 보상의 양을 계산하여서 보상을 줍니다.

컨트랙트 코드 분석을 하였는데, 이 글을 읽으시는 독자분들 중 블록체인 개발자분들이 있으면 컨트랙트 코드와 테스트 코드를 보시면서 레이어2를 사용하는데 레이어1에서 사용하는 것과 똑같다는 의문을 가지시는 분들이 있을 것 같습니다.

맞습니다. 이더리움의 스마트 컨트랙트는 이더리움 가상머신(EVM)에서 구동되도록 되는데 옵티미스틱 롤업은 스마트 컨트랙트에서 발생하는 트랜잭션을 처리하기 위해서 가상머신인 옵티미스틱가상머신(OVM)을 별도로 구현하고 있고 EVM에 기반한 기존 명령어를 옵티미스틱 전용 명령어로 변경해주기 때문에 똑같은 개발코드로 쉽게 개발할 수 있습니다.

어떻게 이더리움 가상머신(EVM) 명령어를 옵티미스틱 가상머신(OVM)으로 변경시키는 자세한 내용을 아시고 싶은 분은 해당 링크에서 확인할 수 있습니다.

하지만 레이어2의 체인을 이용하는 것이기 때문에 config파일세팅과 레이어1 <-> 레이어2 간의 통신등을 해야하기 때문에 다른 점들도 있습니다. 아래에서는 이런 차이점에 대해서 알아보겠습니다.

컨트랙트구성과 차이점

위의 그림은 이번 레이어2의 스테이킹 서비스에 사용된 컨트랙트입니다.

컨트랙트는 Layer1 : ERC20 컨트랙트, Gateway : OVM_L1ERC20Gateway 컨트랙트, Layer2 : L2DepositedERC20, L2StakingERC20 컨트랙트로 구성되어 있습니다.

레이어1의 ERC20 컨트랙트는 이번 테스트에 필요한 ERC20 토큰을 만들어줍니다. 그리고 OVM_L1ERC20Gateway 컨트랙트는 레이어1에서 만든 ERC20 토큰을 레이어2 이동시키고, 나중에 다시 레이어2에서 레이어1으로 토큰을 이동시켜줍니다.

레이어2의 L2DepositedERC20는 레이어2로 이동된 L1자산을 나타내는 ERC20으로 레이어2에서 토큰을 발행, 소각합니다. 그리고 L2StakingERC20는 토큰을 스테이킹 할수있고 스테이킹에 따른 보상을 줍니다.

만약 레이어1에서만 사용되는 스테이킹 서비스였다면 레이어1에 ERC20과 Staking 컨트랙트만 이용해서 간단하게 스테이킹 서비스를 구성할 수 있는 장점이 있지만 그만큼 서비스를 이용하는데 수수료가 많이 든다는 단점이 있습니다.

수수료차이가 얼만큼 나는지 간단하게 확인하는 방법은 https://optimism.io/gas-comparison에서 수수료차이를 확인할 수 있습니다.

해당 페이지에서 유니스왑 버튼을 클릭하면 유니스왑에서의 레이어1과 레이어2의 수수료 차이가 약 10배가 나는 것을 볼 수 있습니다. 이러한 수수료차이 때문에 Uniswap, Synthetix등 많은 서비스들이 옵티미즘 환경에서 서비스를 준비 중입니다.

레이어2를 사용하기 위해서는 컨트랙트 배포를 레이어1과 레이어2 두군데 다하여야 하기 때문에 네트워크를 관리하는 config.js 파일도 레이어1만 배포할때와는 다르게 레이어2 네트워크를 추가 하여야 합니다.

도커세팅을 확인하면 l1_chain은 9545포트로 실행되고 있고, l2geth는 8545포트로 실행되고 있는 것을 확인할 수 있습니다.

networks: {
hardhat: {
accounts: {
mnemonic: 'test test test test test test test test test test test junk'
}
},
// Add this network to your config!
optimism: {
url: 'http://127.0.0.1:8545',
accounts: {
mnemonic: 'test test test test test test test test test test test junk'
},
gasPrice: 0,
ovm: true
},
}

config파일에서 hardhat이 레이어1 네트워크를 구성하며 포트는 9545를 사용하고 optimism은 레이어2 네트워크를 구성하며 포트는 8545를 사용합니다.
이렇게 config파일에서 레이어1 네트워크쪽만이 아닌 레이어2 네트워크쪽도 설정하여 레이어2 네트워크도 사용할 수 있게 해줍니다.

이렇게 config파일을 세팅 후 테스트코드에서 컨트랙트를 배포하고 사용할때도 차이가 있습니다.

const factory__L1_ERC20 = factory('ERC20') //레이어1
const factory__L2_ERC20 = factory('L2DepositedERC20', true) //레이어2
const factory__L1_ERC20Gateway = getContractFactory('OVM_L1ERC20Gateway') //레이어1
const factory__L2_Staking = factory('L2StakingERC20', true) //레이어2

위와 같이 레이어2는 factory( ~, true)로 여기서 true를 하지않으면 evm을 사용하는 레이어1에서 컨트랙트를 사용하고, true를 하면 ovm을 사용하는 레이어2에서 컨트랙트를 사용하겠다는 설정입니다.

const l1RpcProvider = new ethers.providers.JsonRpcProvider('http://localhost:9545') //레이어1const l2RpcProvider = new ethers.providers.JsonRpcProvider('http://localhost:8545') //레이어2

위의 코드는 RPCProvider를 레이어1과 레이어2의 포트에 맞게 연결을 해줍니다.

const l1MessengerAddress = '0x59b670e9fA9D0A427751Af201D676719a970857b'const l2MessengerAddress = '0x4200000000000000000000000000000000000007'

이더리움 클라이언트는 메시지 콜을 하는 트랜잭션을 받으면 이를 메시지로 변환하는데, 레이어1과 레이어2 사이에 메시지를 주고 받을 때 메시지를 받을 주소를 각각 설정해줍니다.

이더리움과 옵티미즘이 트랜잭션을 어떻게 주고 받는 지 자세한 내용을 아시고 싶은 분은 해당 링크에서 확인할 수 있습니다.

const watcher = new Watcher({
l1: {
provider: l1RpcProvider,
messengerAddress: l1MessengerAddress
},
l2: {
provider: l2RpcProvider,
messengerAddress: l2MessengerAddress
}
})

사용자가 레이어1이나 레이어2로 보낸 트랜잭션이 해당 체인에서만 실행이 끝나는 것이 아니라 다른 체인에 영향을 미치는 경우 이와 같이 후처리를 대기할 수 있도록 Watcher서비스를 사용합니다.

마치며

레이어2를 사용하여 스테이킹 서비스를 하는 방법과 레이어1과 레이어2에서의 설정차이에 대해서 알아보았습니다. 레어이2에서는 수수료 가격이 레이어1보다 싸기 때문에 점차 많은 서비스들이 레이어2에서 사용이 될 것입니다. 레이어2에서 서비스하고싶지만 레이어2에 대해서 잘몰라서 서비스를 못하시는 분들도 이 예제를 활용하면 누구나 쉽게 레이어2에서 스테이킹 서비스를 사용 할 수 있습니다.

--

--