시작하는 말
루니버스에서는 루니버스 콘솔과 Solidity IDE를 제공하여 Solidity 기반의 스마트 컨트렉트를 지원한다. 이러한 스마트 컨트렉트를 작성함에 있어서 보안 사항에 대하여 몇가지 체크해야 하는 사항들이 있다. 이번 글에서는 그 중 pull over push에 대하여 알아보도록 하겠다. 또한, 스마트컨트렉트를 작성할 때 추가로 체크해야 할 리스트도 살펴보는 시간을 가져보도록 하겠다.
루니버스에서 지원하는 스마트컨트렉트 배포 환경
https://guide.luniverse.io/v1.3.0/docs/smart-contract-작성하기-1

Pull over Push 개요
Pull over Push는 함수를 한 번에 실행하여서 일방적으로 Push 하는 방식에서 트랜잭션을 받는 사람이 요청하여서 받는 Pull 방식으로 변환하여 하나의 함수를 나누어서 보안성을 높이는 방식을 의미한다. 주로 자산을 주고받을 때 이러한 Pull over Push가 적용되지만 이를 응용하여 다른 케이스에서도 활용이 가능하다. 이번 글에서는 이러한 Pull over Push를 2가지 예시를 통해서 완벽히 숙지하고 가는 것을 목표로 하도록 하겠다.
Pull over Push(for external calls) - payments 케이스
먼저 대표적인 예시를 살펴보도록 하겠다. 컨센시스의 Ethereum Smart Contract Best Practices 중 Ethereum Smart Contract Best PracticesSecure Development Recommendations에서 가져온 예시이다. Pull Over Push 케이스가 적용 가능한 대표적인 예시라고 할 수 있다.
external call의 경우 의도적으로 또는 실수로 fail이 일어날 수 있다. 이러한 fail로 부터의 피해를 최소화 하기 위하여 external call에 해당하는 부분만을 따로 분리하여서 수신자가 call 할 수 있게 만들어서 실행하는 것이 안전하다. 이러한 패턴이 가장 많이 쓰이는 부분은 지급과 같은 기능에서 많이 쓰인다. 유저들에게 자금을 자동으로 넘겨주는(Push) 방식을 유저들이 직접 함수를 불러와서 자금을 인출을 하게 하는 방식(Pull)으로 바꿔서 구현하는 것이다. 이렇게 한 번에 여러 개의 이더를 전송하는 트랜잭션이 발생하지 않게 되어서 가스 리밋에 대한 문제도 최소화 할 수 있게 된다.
코드를 통하여 살펴보도록 하겠다. 이번에 살펴볼 예시는 auction이라는 컨트렉트다. 여기에 포함된 함수는 bid라는 함수가 유일하다. bid 함수는 현재 보내는 트랜잭션에 포함된 이더리움의 양(msg.value)이 기존에 존재하는 highestBid 보다 크다면 해당 이더리움(highest Bid) 만큼이 스마트컨트렉트에 들어오게 되면서 highestBidder와 highestBid를 업데이트를 해주는 역할을 하고 있다.
이를 간단하게 3개의 프로세스로 나눈다면 다음과 같은 3단계로 나눌 수 있다.
3단계로 나누어본 auction의 프로세스
1. 조건을 검사한다.
2. 조건을 통과할 시 call을 보낸다.
3. 새로운 결과값을 업데이트 한다.
이렇게 3단계로 나뉠 수 있을 것이다.
auction.sol (bad case)
// bad
contract auction {
address highestBidder;
uint highestBid;
function bid() payable {
require(msg.value >= highestBid);
if (highestBidder != address(0)) {
(bool success, ) = highestBidder.call.value(highestBid)("");
require(success); // if this call consistently fails, no one else can bid
}
highestBidder = msg.sender;
highestBid = msg.value;
}
}
먼저 Pull over Push가 적용되지 않은 bad case를 보도록 하겠다. bid라는 함수 하나 안에 highestBidder라는 call이 나와 있다. 해당 call은 기존의 highestBidder에게 보냈던 highestBid 만큼의 이더를 환불해주는 call이다. 그러나 해당 call은 call외에도 조건을 검사하는 문항이 들어가 있다. 따라서 누군가 실수로 또는 악의적으로 지속해서 call에 대한 fail이 일어나게 한다면 다른 누구도 bid를 할 수 없게 된다. 앞서 말한 3단계의 프로세스가 하나의 함수에 모두 들어가 있는 케이스인 것이다.
그렇다면 pull over push를 적용하여 이를 더 안정성 있게 만들어 보도록 하겠다.
auction.sol (good case)
// good
contract auction {
address highestBidder;
uint highestBid;
mapping(address => uint) refunds;
function bid() payable external {
require(msg.value >= highestBid);
if (highestBidder != address(0)) {
refunds[highestBidder] += highestBid; // record the refund that this user can claim
}
highestBidder = msg.sender;
highestBid = msg.value;
}
function withdrawRefund() external {
uint refund = refunds[msg.sender];
refunds[msg.sender] = 0;
(bool success, ) = msg.sender.call.value(refund)("");
require(success);
}
}
다음과 같은 코드는 위의 3단계의 프로세스 중 조건을 검사하는 1번 프로세스와 새로운 결과를 업데이트하는 3번 프로세스를 bid 함수에 넣어두고. call이 일어나는 2번 프로세스만 따로 분리하여 withdrawRefund 함수로 분리한 코드입니다. bid 함수에서 조건을 검사하고 refund라는 mapping을 만들어서 mapping 안에 기록을 먼저 해두고 새로운 결과에 대한 변수를 업데이트 한다. 이렇게 refund라는 mapping안에 미리 모든 유저의 refund 값을 기록해 두기 때문에 나중에 사용자들은 각자의 refund mapping을 찾아서 인출을 진행하면 된다.
유저들이 withdrawRefund를 실행하여 자신의 refund mapping의 key 값에 트랜잭션을 보낸 자신의 주소 값이 들어가게 되고 해당하는 매핑의 value에 들어있는 값 만큼을 인출 받게 된다. 이렇게 한번에 검사하고 이더를 push 하던 프로세스를 조건을 검사하고 조건에 통과할 경우에 사용자들이 pull 요청을 하여 이더를 받아가는 식으로 수정이 가능하게 된다.
Pull over Push Case - Ownable Case
이번에는 위의 예제를 응용하여 owner에 대한 권한을 넘겨받는 부분을 Pull over Push 형태로 바꾸어 보겠다. 기존의 코드에서는 ownership을 보내버리면 한 번의 프로세스로 체크 없이 바로 보내(push)버리지만. ownable에서도 Pull over Push를 적용하면 new owner가 먼저 제안이 되고 그 후에 new owner가 ownership을 accept 하는 식(pull)으로 변경 시킬 수 있다.
이해하기 쉬운 설명을 위하여 최소한의 부분만을 보여주는 코드이기 때문에, 실제 owner에 대한 부분은 open-zepplin의 Ownable.sol 컨트렉트를 사용하는 것을 추천한다.
기존 코드의 프로세스
Owner가 transferOwnership을 실행하면 newOwner한테 Ownership이 넘어간다.
하나의 프로세스로 이루어지면 newOwner가 pull을 하는 부분이 없음을 볼 수 있다.
기존 코드
pragma solidity ^0.4.24;
contract Ownable {
address private _owner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
constructor () internal {
_owner = msg.sender;
emit OwnershipTransferred(address(0), _owner);
}
modifier onlyOwner() {
require(isOwner(), "Ownable: caller is not the owner");
_;
}
function transferOwnership(address newOwner) public onlyOwner {
_transferOwnership(newOwner);
}
function _transferOwnership(address newOwner) internal {
require(newOwner != address(0), "Ownable: new owner is the zero address");
emit OwnershipTransferred(_owner, newOwner);
_owner = newOwner;
}
}
위의 코드를 pull로 수정한다면 아래와 같은 문제를 해결할 수 있다.
transferOwnership에서 newOwner를 잘못 입력했을 때 주소값이 0인 경우만 예외로 처리하기 때문에 잘못된 주소를 입력받아서 컨트랙트의 Owner가 잘못 지정되거나 존재하지 않는 주소로 Owner의 권한이 가서 Owner가 존재하지 않는 주소로 지정되는 케이스가 발생할 수 있다.
⇒ pull 방식에서는 newOwner가 accept를 pull 하는 과정을 추가하여 Owner가 제대로 승인을 했는지 체크하고 transferOwnership을 다시 보내므로 제대로 된 검증되고 유효한 newOwner만 새로운 Owner가 될 수 있다.
수정할 코드의 프로세스
- Owner가 newOwner에게 askOwnership을 보낸다.
- newOwner가 Owner에게 acceptOwnership를 보낸다.
- Owner가 transferOwnership을 실행한다.
3개의 프로세스로 이루어지며 newOwner가 pull을 하고 나서 owner가 변경되는 것을 볼 수 있다.
추가할 부분 1 구조체 & mapping
push 방식으로 바꾸기 위하여 owner를 바꾸는 것에 대한 정보를 기록해둘 mapping과 struct를 새로 선언해주어야 한다.
/**
* @dev OwnerInfo and Owner is used for transferOwnership function
*/
struct OwnerInfo {
bool OwnerAsked;
bool OwnerAccept;
}
mapping(address => OwnerInfo) private Owner;
추가할 부분 2 – 함수에서 (askOwnership, acceptOwnership) 부분을 순차적으로 체크하고 난 후 push에 해당하는 _transferOwnership 함수를 실행한다.
/**
* @dev Transfers control of the contract to a newOwner.
* @param newOwner The address to transfer ownership to.
*/
function _transferOwnership(address newOwner) internal {
require(newOwner != address(0), "Ownable: new owner is the zero address");
require(Owner[newOwner].OwnerAccept == true, "Ownable: newOwner didn't accept the request");
emit OwnershipTransferred(_owner, newOwner);
_owner = newOwner;
}
/**
* @dev to adjust Pull-Over-Push Pattern new owner should be proposed first and stored in an intermediate variable
*/
function askOwnership(address newOwner) public onlyOwner{
OwnerInfo storage ownerInfo = Owner[newOwner];
ownerInfo.OwnerAsked = true;
}
/**
* @dev to adjust Pull-Over-Push Pattern proposed owner should be able to accept ownership of the contract
*/
function acceptOwnership() public {
OwnerInfo storage ownerInfo = Owner[msg.sender];
require(ownerInfo.OwnerAsked == true, "Ownable: Owner did not make a request");
ownerInfo.OwnerAccept = true;
}
수정된 코드
pragma solidity ^0.4.24;
contract Ownable {
address private _owner;
struct OwnerInfo {
bool OwnerAsked;
bool OwnerAccept;
}
mapping(address => OwnerInfo) private Owner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
constructor () internal {
_owner = msg.sender;
emit OwnershipTransferred(address(0), _owner);
}
modifier onlyOwner() {
require(isOwner(), "Ownable: caller is not the owner");
_;
}
function isAsked() public view returns (bool) {
return Owner[msg.sender].OwnerAsked;
}
function transferOwnership(address newOwner) public onlyOwner {
_transferOwnership(newOwner);
}
function _transferOwnership(address newOwner) internal {
require(newOwner != address(0), "Ownable: new owner is the zero address");
require(Owner[newOwner].OwnerAccept == true, "Ownable: newOwner didn't accept the request");
emit OwnershipTransferred(_owner, newOwner);
_owner = newOwner;
}
/**
* @dev to adjust Pull-Over-Push Pattern new owner should be proposed first and stored in an intermediate variable
*/
function askOwnership(address newOwner) public onlyOwner{
OwnerInfo storage ownerInfo = Owner[newOwner];
ownerInfo.OwnerAsked = true;
}
/**
* @dev to adjust Pull-Over-Push Pattern proposed owner should be able to accept ownership of the contract
*/
function acceptOwnership() public {
OwnerInfo storage ownerInfo = Owner[msg.sender];
require(ownerInfo.OwnerAsked == true, "Ownable: Owner did not make a request");
ownerInfo.OwnerAccept = true;
}
}
보안성 향상을 위한 그 외의의 체크 리스트
스마트컨트렉트를 모두 작성하고 체크가 가능한 간단한 사항들에 대해서도 추가로 설명하겠다. 기본적인 내용이지만 누락된 경우를 종종 볼 수 있다. 가볍게 읽어보고 혹시 놓친 부분이 있는지 한번 보는 것을 추천한다.
1. require문에서 메세지가 제대로 들어가있는지 체크하기
에러 메세지에 대한 표기가 되어 있는 것은 필수는 아니지만, 디버깅을 할 때 매우 유용한 정보이다. 에러 메세지의 양식을 참고하고 싶다면 ERC20, 721등의 산업 표준을 만든 open-zeppplin의 스마트 컨트렉트를 참고하여 보는 것을 추천한다.
ERC20 표준의 decreaseAllowance 함수에 포함된 require 문을 살펴보도록 하겠다.
에러 메세지가 없는 코드
require(currentAllowance >= subtractedValue)
에러 메세지가 추가된 코드
require(currentAllowance >= subtractedValue, "ERC20: decreased allowance below zero");
2. visibility는 최대한 상황에 맞게 선언하기 생략되었다면 선언해 주기
visibility는 최대한 자세하게 명시를 해놓는 것이 좋다. 모두 public으로 해두어도 스마트 컨트렉트가 작동하긴 하겠지만 보안성을 위하여서 모두 자세히 명시하는 것을 추천한다. 만약 모든 코드에서 쓰인다면 public, 상속해 와서 쓰인다면 internal, 내부적으로만 쓰이고 상속도 없다면 private, 외부에서 호출한다면 external로 구분하여 다른곳에서 사용하는 곳이 없는지 테스트를 모두 해보고 최대한 상황에 맞게 명시를 해준다면 더 안전한 스마트 컨트렉트라고 볼 수 있다.
3. 이벤트값이 누락되어 있지 않은지 체크한다.
이벤트값은 필수는 아니지만 사실상 ERC20과 같은 표준에서는 모든 함수에 event 처리를 해주게 되어 있다. 블록체인에서 상태변화가 일어나지 않는 이상 스마트컨트렉트의 값들에 대한 변화는 알 수 없음으로 데이터를 기록하고 이를 통하여 관리나 점검, 또는 다양한 방향으로 활용하기 위하여서는 가급적이면 event 값이 필요한지 여부를 고려하여 넣어주도록 한다. 또한 검색이 필요한 주소 값 같은 데이터들에는 미리 indexed를 달아두어 다음에 검색이 가능하도록 스마트컨트렉트를 작성하는 것이 중요하다.
마무리하며

이번에 알아본 Pull over Push와 체크리스트 이외에도 보안성을 향상하기 위해 알아두어야 할 다양한 기법들이 있다. 스마트 컨트렉트는 한번 배포되고 나면 수정이 어렵게 때문에 보안성이 특히 더 중요하다. 보안 사항이 누락된 잘못 배포된 스마트컨트렉트의 경우 시스템에 치명적인 영향을 끼치게 된다.
하지만, 처음 스마트컨트렉트를 접하거나 빠르게 작성해야 하는 경우 이러한 보안사항들을 지키며 코드를 작성하는 것이 쉬운 일이 아니다. 이런 경우 루니버스 콘솔을 사용하는 방법을 추천한다. 루니버스 콘솔에서는 이미 보안에 대한 검증이 완료된 스마트컨트렉트를 아래의 그림과 같은 화면에서 한 번의 클릭으로 손쉽게 만들 수 있는 환경을 제공한다. 또한 이미 수많은 프로젝트가 루니버스 안에서 작동하고 있기 때문에 많은 유즈케이스가 있는 만큼 더 보안성이 높다고 볼 수 있다.
루니버스 스마트컨트렉트 배포 가이드
https://guide.luniverse.io/v1.3.0/docs/user-contract-gui로-배포하기
참고 링크
https://consensys.github.io/smart-contract-best-practices/recommendations/