ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [solidity] 컨트랙트에서 이더전송시 알아둬야 할 사항들에 관하여 call, send, transfer, receive(), fallback(), payable
    solidity 2023. 3. 22. 15:20
    반응형

    이더를 전송하고 받는 부분에 있어서 다양한 함수가 있다. 한가지 동작을 하는데 다양한 방법이 있다라는것은 그만큼 다양한 상황속에서 고려될수 있는 사항이 있다라는 점이고 주요하게 다뤄줘야 하는 부분이라는 이야기다. 근데 보통 사용하는 방법만 항상 사용하는 경향이 있기 때문에 다른 방법들은 놓치기 쉽다. 그래서 정리를 한번 해보고자 작성하는 글이다.

     

    일단 이더를 보낼때와 받는때로 구분해서 살펴보고자 한다

    보낼때 : call, send, transfer

    받을때 : payable, receive(), fallback()

     

    보낼때

    보내는 부분역할을 할수 있는 간단한 Bank1 컨트랙트이다. deposit을 하게 되면 컨트랙트에 예치가 되고 withdraw 를 통해 컨트랙트내의 이더중 나의 예치량 만큼 송금할수 있는 기능이며 withdraw 를 call, send, transfer 세가지 방법으로 구현한 코드이다.

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.7;
    
    contract Bank1 {
        // 예금자주소 => 금액
        mapping (address => uint) public balance;
        event log(bytes data);
    
        function deposit() public payable returns(uint){
            balance[msg.sender] += msg.value;
            return msg.value;
        }
    
        function withdrawByCall(address payable _addr, uint _amount) public {
            require(balance[msg.sender] > _amount,"Insufficient Balance");
            balance[msg.sender] -= _amount;
            (bool success, bytes memory data ) = _addr.call{value: _amount}("");
            emit log(data);
            require(success, "Transfer Failed");
        }
    
        function withdrawBySend(address payable _addr, uint _amount) public {
            require(balance[msg.sender] > _amount,"Insufficient Balance");
            balance[msg.sender] -= _amount;
            (bool success) = _addr.send(_amount);
            require(success, "Transfer Failed");
        }
    
        function withdrawByTransfer(address payable _addr, uint _amount) public {
            require(balance[msg.sender] > _amount,"Insufficient Balance");
            balance[msg.sender] -= _amount;
            _addr.transfer(_amount);
        }
    
        function getMyBalance() public view returns(uint){
            return balance[msg.sender];
        }
    }

    코드를 통해 알수 있는 부분은 함수 호출이후에 반환부에 대한 차이이다.

    transfer 같은경우에는 반환해주는 값이 없다 그냥 전송하고 실패하면 해당 호출에서 진행됬던 사항들이 롤백된다.

    즉 실패시에 함수안에서 이미 진행된 처리가 무효화 되기 때문에 가장 안전하다고 느낄수 있는 전송방법이다.

     

    send와 call 의 경우 전송이후 전송에 대한 반환값으로 bool(전송성공 여부)를 반환해준다.

    이와같은 경우 실패하더라도 위의 코드의 실행이 롤백되지않기 때문에 체인상에 저장된 balance는 출금이되고 실제로는 전송이 안되어 있는 버그현상을 유발할 가능성이 생기게된다.

    따라서 반환 받은 bool 값을 사용해서 re-entry-guard 역할을 할수 있는 코드가 필요하다.

    위의 코드에서는 require 문을 통해 성공하지않은경우에는 롤백이 되는 코드를 추가했다.

     

    솔리디티 같은경우는 배포시 cost 가 코드량에 비례하기 때문에 일반적인 전송기능에는 transfer를 사용하는게 효율적으로 보인다.

    다만 경우에 따라 성공과 실패시에 다른 처리가 필요할수 있는경우에는 send나 call을 통해서 처리해 줄수 있다는 점을 알아두면 좋을거 같다. 관련해서 좋은 예시가 있다면 추후에 추가 해둬야 겠다.

     

    그러면 send와 call은 어떤 차이가 잇을까?

    send와 다르게 call의 경우는 더 다양하게 사용할수 있게 되는데 따라서 단순히 이더전송을 위한 함수라기 보다는 이더전송도 할수 있다 정도로 알아두는게 좋다.

    (bool success, bytes memory data ) = _addr.call{value: _amount}("");

    value 값 전달이외에도 함수 호출시에 인자로 넘겨줄수 있게된다.

    bytes memory data = abi.encodeWithSignature("abcdefg(address, uint)", _addr, _value);

    (bool success, bytes memory data ) = _addr.call{value: _amount}(data);

    이런식으로 넘겨주면 다른 컨트랙트의 함수를 호출할수 있게 되는대 그때 value 값을 이용하는 코드라고 보면된다 이렇게 사용하는 경우에는 _addr은 다른 함수를 가지고 있는 컨트랙트의 주소가 된다.

     

    반환값으로도 성공실패여부와 inputdata 값을 받아서 분기 처리 해줄수도 있다.

    즉 call을 이더전송으로 사용한다라고 보기보다는 다른 컨트랙트의 함수호출용도로 사용한다라고 생각하고 사용하는게 좋아보이고 그때 이더가 필요한 함수를 호출하는 경우에 전송기능을 함께 사용할수 있다라고 인지하면 된다.

     

    받을때

    가장 기본적인 케이스 부터 살펴보자

    contract Bank2 {
        // 예금자주소 => 금액
        mapping (address => uint) public balance;
        event log(string text);
    
        function deposit() public payable returns(uint){
            balance[msg.sender] += msg.value;
            return msg.value;
        }
    
        function getBalanceByAddr(address _addr) public view returns(uint){
            return balance[_addr];
        } 
    }

    1. deposit 함수처럼 payable 키워드가 사용된 함수를 통해서 msg.value를 컨트랙트가 받을수 있다.

    value를 1000wei로 하여 deposit 함수를 호출하였고 contract의 Balance에 1000wei(0.000.....01 eth)가 있는 것을 확인할수 있다.

     

    2. 이번에는 앞에서보낼때 사용했던 예시인 Bank1 컨트랙트에서 withdraw 를 통해서 Bank2로 보내보았다.

    Bank1에서 Bank2 CA 주소로 100wei 만큼 전송 했지만 실패한 부분을 확인할수 있다.

    3. Bank2 contract에 fallback 과 receive 함수를 넣어보고 다시 보내 보았다.

    contract Bank2 {
        // 예금자주소 => 금액
        mapping (address => uint) public balance;
        event log(string text);
    
        function deposit() public payable returns(uint){
            balance[msg.sender] += msg.value;
            return msg.value;
        }
    
        function getBalanceByAddr(address _addr) public view returns(uint){
            return balance[_addr];
        } 
    
        receive() external payable {
            emit log("in receive");
        }
        fallback() external payable {
            emit log("in fallback");
        }
    }

     

     

    결과는 받아졌고 in receive 라는 로그를 확인할수 있었다.

    그리고 다음에는 receive 함수는 빼고 fallback 함수만 넣고 받아봤는데 그래도 받아졌고 in fallback 이라는 로그를 확인할수 있었다.

     

    이제 fallback 함수와 receive 함수는 언제작동하는지 알아보고 위 상황을 바라봐 보자

     

    recieve 함수가 실행되는 경우

    - 이더를 받으면서 msg.data 가 존재하지 않는경우 => 함수호출이 아닌경우

     

    fallback 함수가 실행되는 경우

    - 존재하지 않는 함수가 실행이 되는경우

    - 이더는 받았는데 receive함수가 없거나 msg.data가 존재하는 경우

     

    조금 헤깔릴수가 있는데 케이스들을 한번 생각해보자

     

    함수호출인경우는 받을때의 첫번째 예시처럼 컨트랙트내의 deposit 함수에 의해서 value 가 전달이 되는경우이다.

    근데 payable 키워드가 붙어있기 때문에 fallback 이나 recieve 함수가 실행되지 않고 바로 이더를 받을수 있다.

     

    근데 만약 deposit 이 payable 키워드가 없었다면 어떻게 동작했을까?

    그경우는 함수호출인경우(msg.data 가 존재)이고 fallback 함수가 실행되는 경우 2번에 해당한다

    (msg.data 값은 함수의 시그니쳐와 인자의 조합이라고 생각하면 왜 함수호출이 msg.data의 존재와 같다고 여기는지 이해할수있다.)

    이때는 receive가 존재하더라도 msg.data가 존재하기 때문에 fallback함수가 실행이 된다.

     

    이번에는 위의예시처럼 다른 컨트랙트에서(주소)에서 그냥 받는경우이다.

    이때는 해당컨트랙트 입장에서 함수호출에 의해서 이더가 전송된것이 아니라 그냥 다른주소에서 이더를 받게된경우이다. 따라서 msg.data가 존재하지 않고 receive 함수가 있기때문에 fallback 보다 우선해서 receive함수가 실행이 되게된다.

     

    한번 이해하면 어려운 내용은 아닌데 좀 말이 그말이 그말같고 헤깔리는 부분이 있어서 실습해보면서 진행해 보면 좋은 내용일거 같아서 진행하면서 적어봤다.

     

Designed by Tistory.