블록체인

[ethereum] solidity withdrawals pattern(출금패턴)

멍개. 2022. 8. 28. 07:44

solidity에서 출금패턴을 다뤄보겠습니다.

 

제가 이 포스팅을 위해 삽질한 과정을 보고 싶다면

여기로 오시면 되겠습니다. 약 2시간 30분동안 삽질을 하였습니다.

 

☞ 목차

1. 준비
2. 입/출금 가능한 스마트 컨트랙트
3. 출금패턴
4. truffle 출금 패턴 테스트 코드 작성

● 준비

네트워크 준비, remix 네트워크 연결, metamask 네트워크 연결 및 계정준비

· 네트워크 구축

$ ganache-cli

Ganache CLI v6.12.2 (ganache-core: 2.13.2)

Available Accounts
==================
(0) 0xF3133758a5b12c99bdF762F93b86239a7Ef167f6 (100 ETH)
(1) 0x84A220f3bF83883eE93793F05506Ba512AfcBCCf (100 ETH)
(2) 0xCbf363bfbf2958725EDC0409dCd4Ea90cB94f7b6 (100 ETH)
(3) 0x7Af7683706De1F73871f7a5b5D81445E3EaE9AB2 (100 ETH)
(4) 0x9748D4c914f1b3a7B159992a0E133ad99F67EaDF (100 ETH)
(5) 0xA5a4E8d304D9D5BEc3076225d590d3bD9ccd22A2 (100 ETH)
(6) 0x14c3a4b9B0Ce420d89Ef188E1FAa536a6F4CEe25 (100 ETH)
(7) 0xC72C4B6e309b0a5A738694189523Ffa3acC86D34 (100 ETH)
(8) 0xfc6bA3aDd220Eb1B4333bbafF02C99a96c445477 (100 ETH)
(9) 0x84b5be354D9a343C0f16b98cE54E98Dcd0A57397 (100 ETH)

Private Keys
==================
(0) 0x2a137f79de5f0ade20e53bad9f7d73fa5852340df105e5b7d189f634eee6f4a9
(1) 0xfa995841f93e5dd4cdc1b61be3772d1f066b67049c3f58475aab298e66723c14
(2) 0xe38239192310bcde9c4c19a5daa7df1d0e0fa0de821083ecc511bedf5fca0536
(3) 0xec1d2c35bfbfc1c0c119af404cf60794c16d06f459a4653e9daafd94523145f7
(4) 0xc3b32dd7f90dc97bbfec3d2f552398fc94e436f0469e991747cf5f0148d2175f
(5) 0x740f283074364be311a485a5ae9e9cf5dd1ab131aba93721544cee7ee97871b9
(6) 0x162c0ac0a62f9eca99665d421497119cef6ab125022921ffc8638eab17572e67
(7) 0x2fb48a4042e9297165641a0f13f72a76dc8bda75445eb148bc09ab1035b388bd
(8) 0x3d062296e6463ac4e349526afe7ab95cdbc39ed2aca85e7ab5c61bb3784f8788
(9) 0x0fdcfc740f6095312eb2b2e1f4ff5494aca2e10559f08048e132768675177538

HD Wallet
==================
Mnemonic:      stairs another grid call actor genuine chunk hub solve picture weather settle
Base HD Path:  m/44'/60'/0'/0/{account_index}

Gas Price
==================
20000000000

Gas Limit
==================
6721975

Call Gas Limit
==================
9007199254740991

Listening on 127.0.0.1:8545

· remix 로컬 네트워크 연결

Web3 Provider 환경을 선택한 후 ganache-cli로 구축한 환경을 연결합니다.

IP: 127.0.0.1
PORT: 8545

· metamask 로컬 네트워크 연결

metamask는 크롬 확장프로그램으로 제공하는 이더리움 지갑입니다. 지갑은 출금 패턴을 위해 스마트 컨트랙트로 이더리움을 전송해야 하는데 이더리움 전송하기 위해 사용합니다.

ganache-cli에서 제공하는 지갑을가져오고 싶다면 각각의 개인키(private key)를 개별로 가져와도 되고 니모닉(mnemonic)으로 가져와도 됩니다.

니모닉으로 지갑 복구하기
개인키로 가져오기

 

● 입/출금 가능한 스마트 컨트랙트

출금패턴을 이해하기 위해선 스마트 컨트랙트가 이더리움을 어떤 식으로 입금/출금 하는지 이해해야 합니다.

· 입금 가능한 스마트 컨트랙트 만들기

solidity는 address payable 타입을 제공합니다. address와 유사하지만 다른 타입입니다. address payable 타입은 transfer() 메서드와 balance 속성을 제공합니다. transfer() 메서드는 to.transfer()의 형태로 작성 가능합니다.

to.transfer(amount)는 해당 스마트 컨트랙트에서 to에게 amount 만큼 이더리움을 전송합니다.

// SPDX-License-Identifier: GPL
pragma solidity 0.8.10;

contract Transfer {
    constructor () {}

    function balanceOf(address payable to) external view returns(uint) {
        return to.balance;
    }

    function transfer(address payable to, uint amount) external {
        to.transfer(amount);
    }
}

해당 컨트랙트를 배포합니다.

배포된 컨트랙트 주소(CA): 0xd1371201e390482429aF6BE4375604B790095391

이제 metamask로 해당 컨트랙트로 이더를 전송합니다.

1이더 전송을 시도합니다.

이더리움 전송을 실패합니다. 그 이유는 스마트 컨트랙트가 이더리움을 전송받기 위해선 특수한 함수가 필요합니다.

// SPDX-License-Identifier: GPL
pragma solidity 0.8.10;

contract Transfer {
    constructor () {}

    receive() external payable {}

    function balanceOf(address payable to) external view returns(uint) {
        return to.balance;
    }

    function transfer(address payable to, uint amount) external {
        to.transfer(amount);
    }
}

바로 receive() external payable 함수입니다. 해당 함수는 지갑으로 이더리움을 전송할 때 호출되는 함수입니다.

배포된 컨트랙트 주소(CA): 0x4d3063CD0e79DDF4d636b344802126F06CB0f41b

마찬가지로 metamask를 통해 해당 컨트랙트로 이더리움을 전송할 수 있습니다.

성공적으로 1ETH를 전송했습니다.

스마트 컨트랙트에 구현한 balanceOf 함수를 이용해서 스마트 컨트랙트의 이더리움 잔액 조회를 할 수 있습니다.

해당 컨트랙트에 1ETH가 조회되는 모습을 확인할 수 있습니다.

payable 키워드는 receive() 뿐 아니라 생성자(constructor) 또는 다른 함수에도 포함할 수 있습니다.

// SPDX-License-Identifier: GPL
pragma solidity 0.8.10;

contract Transfer {
    uint public amount = 0;
    
    constructor () payable {
        amount = msg.value;
    }

    receive() external payable {}

    function balanceOf(address payable to) external view returns(uint) {
        return to.balance;
    }

    function transfer(address payable to, uint _amount) external {
        to.transfer(_amount);
    }

    function test() public payable {
        amount = msg.value;
    }
}

payable로 지시된 함수는 remix 기준으로 다른 형태의 버튼UI로 표기됩니다.

Deploy 버튼을 보면 적색으로 표시됩니다. Deploy 버튼은 constructor()에 영향을 받습니다.

remix는 트랜잭션을 발생할 때 이더리움을 전송해야 한다면 녹색으로 표시한 VALUE 영역에 트랜잭션을 발생할 때 전송할 이더리움을 설정할 수 있습니다.

배포된 컨트랙트 주소: 0x29bbaC9A81b55a20E32aF944641559788207390a

VALUE에 1이더를 설정하고 Deploy 버튼을 눌렀습니다. 배포된 컨트랙트의 balanceOf를 이용하여 해당 컨트랙트의 이더리움 잔액을 확인하면 1이더가 있음을 확인할 수 있습니다.

msg 객체를 이용하여 누가 호출했고, 얼마의 이더를 전송했는지 알 수 있습니다.

msg.sender: 호출자
msg.value : 전송한 이더리움 양

만약, test() 함수를 호출 할 때 value를 입력하고 호출하면 해당 컨트랙트로 입력한 이더리움 양 만큼 전송을 합니다.

· 출금 가능한 스마트 컨트랙트 만들기

해당 컨트랙트에 있는 1ETH를 transfer() 함수를 호출하여 다른 주소로 전송할 수 있습니다. transfer()는 address payable에서 제공하는 메서드입니다.

앞에서 배포한 스마트 컨트랙트 코드에서 구현한 transfer() 함수를 살펴보겠습니다.

function transfer(address payable to, uint _amount) external {
    to.transfer(_amount);
}

우리가 만든 transfer() 함수는 2개의 인자를 전달받습니다.

첫 번째 인자인 to는 이더를 전송받을 주소입니다.

두 번째 인자인 _amount는 전송할 이더입니다. 이더리움은 decimals가 18이므로 1이더를 전송하기 위해선 1000000000000000000 만큼 전송해야 합니다.

스마트 컨트랙트의 1ETH중 0.5ETH 만큼 전송해보겠습니다.

to: 0xF3133758a5b12c99bdF762F93b86239a7Ef167f6
value: 0.5ETH (500000000000000000Wei)

transact 버튼을 누르면 트랜잭션이 발생하기 됩니다.

해당 컨트랙트 코드를 정상적으로 실행하지 못합니다. 그 이유는 출금을 할 경우에도 해당 함수에 payable이 붙어야 합니다.

배포된 컨트랙트 주소: 0x117c47cf20Cd24A88830f63204149A8229E7e269

스마트 컨트랙트의 이더를 다른 주소로 전송해보겠습니다.

to: 0x84A220f3bF83883eE93793F05506Ba512AfcBCCf
value: 0.5ETH (500000000000000000Wei)

스마트 컨트랙트에서 metamask에 존재하는 지갑으로 이더리움 전송을 완료했습니다.

· 스마트 컨트랙트에서 스마트 컨트랙트로 이더리움 전송

앞에서 transfer()를 이용하여 다른 주소로 이더리움을 전송하는 과정을 보았습니다. 컨트랙트 주소도 주소이기 때문에 to가 컨트랙트 주소일 수 있습니다.

그런데 receive()가 아니라 앞에서 test() payable 형태로 구현되어 있는 스마트 컨트랙트가 있을때 해당 컨트랙트의 test()를 호출하면서 이더리움을 전송하기 위해선 다음과 같이 해야합니다.

 
// test() payable 컨트랙트가 정의된 solidity 파일 이름
import './Transfer.sol';

contract TransferBridge {
  function transfer(address _to, uint _amount) external {
    Transfer(_to).test{value: _amount}();
  }
}

이런 형태로 컨트랙트에서 배포된 다른 컨트랙트의 함수를 호출할 수 있습니다. 함수이름과 () 사이에 {value: amount}를 포함하여 해당 기능을 수행할 수 있습니다. TransferBridge로 배포한 스마트 컨트랙트의 transfer를 호출하면 해당 컨트랙트에 있는 _amount 이더리움 만큼 Transfer로 전송합니다.

● 출금패턴

출금패턴은 스마트 컨트랙트의 이더리움을 외부로 전송하는 코드를 분리해서 작성하는 패턴입니다.

· 출금패턴이 필요한 이유

컨트랙트는 transfer()하는 대상이 외부 소유 계정(EOA), 컨트랙트 계정(CA)가 있습니다. transfer()를 호출할 때 EOA로 전송한다면 문제되지 않습니다.

하지만 receive() payable 또는 payable이 존재하지 않는 함수로 이더를 전송하려고 시도한다면? 반대로 컨트랙트의 특정 함수에 대해서만 이더 전송을 시도한다면 EOA는 함수가 존재하지 않기 때문에 문제가 발생할 수 있습니다.

· 출금 패턴을 적용하지 않은 코드 살펴보기

// SPDX-License-Identifier: GPL
pragma solidity 0.8.10;

contract NonWithdrawalContract {
    address payable public richest;
    uint public mostSent = 0;

    constructor() payable {
        richest = payable(msg.sender);
        mostSent = msg.value;
    }

    function becomeEther () external payable {
        if (msg.value > mostSent) {
            // richest가 receive()가 없는 컨트랙트라고 가정해보자!
            payable(richest).transfer(mostSent);
            richest = payable(msg.sender);
            mostSent = msg.value;
        } 
    }
}

해당 코드는 출금패턴을 적용하지 않은 코드입니다.

becomeEther() 함수만 살펴보겠습니다.

function becomeEther () external payable {
        if (msg.value > mostSent) {
            // richest가 receive()가 없는 컨트랙트라고 가정해보자!
            payable(richest).transfer(mostSent);
            richest = payable(msg.sender);
            mostSent = msg.value;
        } 
}

스마트 코드가 잘 동작하다가 receive()를 구현하지 않은 컨트랙트 주소가 richest에 저장되어 있다면?

해당 함수는 어디선가 richest를 바꾸지 않는다면 영영 정상적인 호출을 하지 못합니다. 또는 특정 컨트랙트의 함수를 호출하는 형태로도 작성할 수 있겠죠? 이땐 전송하는 목적지 주소가 반드시 특정 함수가 구현된 컨트랙트 주소여야 합니다.

하지만 스마트 컨트랙트는 주소만 가지고 앞의 여러 상황을 알 수 없습니다.

1. transfer()를 호출하는데 receive()를 구현한 스마트 컨트랙트 주소가 맞는지?
2. 다른 스마트 컨트랙트에 구현된 함수() payable로 전송하는데 스마트 컨트랙트 주소가 맞는지? 
   또는 함수 payable이 정상적으로 구현되어 있는지?

이런 복합적인 상황을 다 검사할 순 없습니다. 물론! 불가능한건 아니지만 너무 비효율적입니다.

여기서 비효율이란? 쓸데없는 검사를 위해 연산량이 올라가고 연산량이 증가하면 수수료가 많이 발생합니다.

· 출금 패턴 적용한 코드 살펴보기

출금 패턴은 아주 간단합니다. 앞에서 입/출금 만들면서 transfer() 함수를 이용하여 특정 함수에 출금에 관련한 코드밖에 없었습니다.

출금 패턴은 출금만 담당하는 코드를 별도로 분리하여 관리하는 것을 의미합니다. NonWithdrawalContract 여기선 becomeEther() 함수에 입금/출금 기능이 같이 있기 때문에 출금에 문제가 생기면 입금도 사용할 수 없습니다. 여기선 입금코드만 있지만 입금 외 여러가지 로직을 포함할 수 있겠죠?

// SPDX-License-Identifier: GPL
pragma solidity 0.8.10;

contract WithdrawalContract {
    address payable public richest;
    uint public mostSent;

    mapping (address => uint) pendingWithdrawals;

    constructor() public payable {
        richest = payable(msg.sender);
        mostSent = msg.value;
    }

    function becomeEther() public payable {
        if (msg.value > mostSent) {
            pendingWithdrawals[richest] += msg.value;
            richest = payable(msg.sender);
            mostSent = msg.value;
        }
    }

    function withdraw() public {
        uint amount = pendingWithdrawals[msg.sender];
        // 리엔트란시(re-entrancy) 공격을 예방하기 위해
        // 송금하기 전에 보류중인 환불을 0으로 기억해 두십시오.
        pendingWithdrawals[msg.sender] = 0;
        payable(msg.sender).transfer(amount);
    }
}

becomeEther()에서 호출한 transfer()를 withdraw() 함수로 옮겨줍니다. 대신 becomeEther()는 이더를 받는 함수이므로 mapping 타입을 이용하여 이더를 받은 주소를 관리합니다. 이렇게 하면 becomeEther()는 항상 성공하며, withdraw()는 msg.sender가 다음 조건을 만족하지 않는 경우에 대해서만 에러가 발생합니다.

1. transfer()를 호출하는데 receive()를 구현한 스마트 컨트랙트 주소가 맞는지? 2. 다른 스마트 컨트랙트에 구현된 함수() payable로 전송하는데 스마트 컨트랙트 주소가 맞는지? 또는 함수 payable이 정상적으로 구현되어 있는지?

● truffle 출금 패턴 테스트 코드 작성

마지막으로 truffle을 이용하여 출금 패턴을 적용하지 않은 코드에 대해서 에러가 나는 상황을 구현하고 이를 테스트하는 코드를 작성해보겠습니다.

이를 정확히 이해한다면, 출금 패턴을 적용하면 에러가 나지 않는 상황을 이해할 수 있을겁니다.

· 프로젝트 생성

truffle은 init 명령어로 프로젝트를 생성할 수 있습니다.

$ mkdir withdraw-patterns

$ cd withdraw-patterns

$ truffle init

error assertions 테스트와 이더리움 단위 변환을 위해 다음 2개의 라이브러리를 설치합니다.

$ npm install --save truffle-assertions
$ npm install --save web3

다음으로 truffle-config.js을 다음과 같이 수정합니다.

module.exports = {
  networks: {
    dev: {
     host: "127.0.0.1",     
     port: 8545,            
     network_id: "*",       
    },
  },
  mocha: {},

  compilers: {
    solc: {
      version: "0.8.10",  
     
    }
  }
};

networks는 스마트 컨트랙트를 배포할 환경 정보를 관리합니다. networks는 json 형태로 여러 환경을 관리할 수 있습니다.

module.exports = {
  networks: {
    dev: {
     host: "127.0.0.1",     
     port: 8545,            
     network_id: "*",       
    },
    propd: {
     host: "192.168.1.1",     
     port: 8546,            
     network_id: "*",       
    },
  },
  mocha: {},

  compilers: {
    solc: {
      version: "0.8.10",  
     
    }
  }
};

· 시나리오 설계

이 부분은 저도 처음에 출금 패턴 테스트 코드를 작성하기 위해 시간이 다소 오래걸렸던 부분입니다.

1. 3개의 컨트랙트를 준비한다.

NonWithdrawalContract: 출금패턴을 적용하지 않은 컨트랙트

PayableContract: receive()가 구현된 컨트랙트

NonPayableContract: receive()를 구현하지 않은 컨트랙트

2. NonWithdrawalContract 배포

3. PayableContract 배포 - 배포할 때 2ETH를 전송한다. constructor()는 payable를 포함합니다.

4. NonPayableContract 배포 - 배포할 때 2ETH를 전송한다. receive()는 constructor()는 payable를 포함합니다.

5. richest가 PayableContract일때와 NonPayableContract인 상황 만들기

대략적인 시나리오는 이정도 입니다.

각각의 시나리오는 truffle에선 다음과 같이 작업할 수 있습니다.

contracts: 1번 

migrations: 2번, 3번, 4번

test: 5번

truffle 프로젝트를 생성하면 3개의 디렉터리를 생성합니다.

contracts: 스마트 컨트랙트 코드 관리
migrations: 배포관리
test: 실질적인 동작을 수행하며, 정상적으로 동작하는지 테스트 가능

· 스마트 컨트랙트 코드 작성

스마트 컨트랙트 코드는 contracts 아래에서 관리합니다. 3개의 코드를 작성합니다.

▶ NonWithdrawalContract.sol

// SPDX-License-Identifier: GPL
pragma solidity 0.8.10;

contract NonWithdrawalContract {
    address payable public richest;
    uint public mostSent = 0;

    constructor() payable {
        richest = payable(msg.sender);
        mostSent = msg.value;
    }

    function becomeEther () external payable {
        if (msg.value > mostSent) {
            // richest가 receive()가 없는 컨트랙트라고 가정해보자!
            payable(richest).transfer(mostSent);
            richest = payable(msg.sender);
            mostSent = msg.value;
        } 
    }
}

▶ PayableContract.sol

// SPDX-License-Identifier: GPL
pragma solidity 0.8.10;

import './NonWithdrawPattern.sol';

contract PayableContract {
  constructor () payable {}

  receive () external payable{}

  function send(address _to, uint _amount) external {
    NonWithdrawalContract(_to).becomeEther{value: _amount}();
  }

  function balanceOf() public view returns(uint) {
      return payable(this).balance;
  }
}
 

▶ NonWithdrawPattern.sol

// SPDX-License-Identifier: GPL
pragma solidity 0.8.10;

import './NonWithdrawPattern.sol';

contract NonPayableContract {
  constructor () payable {}

  function send(address _to, uint _amount) external {
    NonWithdrawalContract(_to).becomeEther{value: _amount}();
  }
}

이렇게 작성된 코드는 compile 명령어를 이용하여 컴파일 할 수 있습니다.

$ truffle compile

Compiling your contracts...
===========================
> Compiling ./contracts/Migrations.sol
> Compiling ./contracts/NonPayableContract.sol
> Compiling ./contracts/NonWithdrawPattern.sol
> Compiling ./contracts/PayableContract.sol
> Compiling ./contracts/WithdrawPattern.sol
> Compilation warnings encountered:

    Warning: Visibility for constructor is ignored. If you want the contract to be non-deployable, making it "abstract" is sufficient.
  --> project:/contracts/WithdrawPattern.sol:10:5:
   |
10 |     constructor() public payable {
   |     ^ (Relevant source part starts here and spans across multiple lines).


> Artifacts written to /Users/jeongtaepark/Desktop/withdraw-patterns/build/contracts
> Compiled successfully using:
   - solc: 0.8.10+commit.fc410830.Emscripten.clang

우리가 작성한 코드가 문제 없으면 다음과 같이 build 디렉터리가 생성되며, 배포를 위한 준비가 끝납니다.

아마 0.8.10 기준으로 warning이 하나 뜨게 되는데 이건 추후에 별도로 다뤄보겠습니다.

· 배포

배포는 migrations 아래에서 관리합니다. 3개의 컨트랙트를 배포하는 코드를 작성합니다.

web3의 utils를 이용하면 이더단위를 편하게 바꿀 수 있습니다. 굳이 0을 17~18번 작성하지 않아도 됩니다.

 

▶ 2_PayableContract_migration.js

const PayableContract = artifacts.require("PayableContract");
const web3 = require('web3');

module.exports = function (deployer) {
  deployer.deploy(PayableContract, {value: web3.utils.toWei("2", "ether")});
};

truffle은 트랜잭션 발생할 때 마지막 인자에 {value: amount, from: address}를 전달하여 트랜잭션 정보를 포함할 수 있습니다.

▶ 3_NonPayableContract_migration.js

const NonPayableContract = artifacts.require("NonPayableContract");
const web3 = require('web3');

module.exports = function (deployer) {
  deployer.deploy(NonPayableContract, {value: web3.utils.toWei("2", "ether")});
};

▶ 4_NonWithdrawContract_migration.js

const NonWithdrawalContract = artifacts.require("NonWithdrawalContract");
const web3 = require('web3');

module.exports = function (deployer) {
  deployer.deploy(NonWithdrawalContract);
};

이제 우리는 배포를 위한 코드 작성을 마쳤습니다.

truffle은 migrate 명령어를 이용하여 스마트 컨트랙트를 배포할 수 있습니다. remix에서 deploy를 누르는 과정으로 이해하면 됩니다.

$ truffle migrate --network dev

이떄 --network 옵션으로 dev를 주게 되는데 이건 truffle-comfig.js의 network에 정의한 dev로 관리중인 정보에 배포한다는 의미입니다.

Compiling your contracts...
===========================
> Everything is up to date, there is nothing to compile.



Starting migrations...
======================
> Network name:    'dev'
> Network id:      1639353530182
> Block gas limit: 6721975 (0x6691b7)


1_initial_migration.js
======================

   Deploying 'Migrations'
   ----------------------
   > transaction hash:    0xaaf21cb53b1b6cf831b871bc8904bf878d38819e2a480169c5365dbc6c69a5be
   > Blocks: 0            Seconds: 0
   > contract address:    0xeb68a074e9C1A482f816202035ce550d30e25253
   > block number:        15
   > block timestamp:     1639359582
   > account:             0xF3133758a5b12c99bdF762F93b86239a7Ef167f6
   > balance:             94.96525818
   > gas used:            248854 (0x3cc16)
   > gas price:           20 gwei
   > value sent:          0 ETH
   > total cost:          0.00497708 ETH


   > Saving migration to chain.
   > Saving artifacts
   -------------------------------------
   > Total cost:          0.00497708 ETH


2_PayableContract_migration.js
==============================

   Deploying 'PayableContract'
   ---------------------------
   > transaction hash:    0xaef5081a44fe3f3f191451b733a95356c19de138109903afecb21e108b480d76
   > Blocks: 0            Seconds: 0
   > contract address:    0x634B0759c0d13599879b97B4f38eaD7A7cB5758D
   > block number:        17
   > block timestamp:     1639359583
   > account:             0xF3133758a5b12c99bdF762F93b86239a7Ef167f6
   > balance:             92.96091778
   > gas used:            174507 (0x2a9ab)
   > gas price:           20 gwei
   > value sent:          2 ETH
   > total cost:          2.00349014 ETH


   > Saving migration to chain.
   > Saving artifacts
   -------------------------------------
   > Total cost:          2.00349014 ETH


3_NonPayableContract_migration.js
=================================

   Deploying 'NonPayableContract'
   ------------------------------
   > transaction hash:    0x2ea04740632169ac427e93903b92700ccd879e79b71fc0b99bab23b826dbddc4
   > Blocks: 0            Seconds: 0
   > contract address:    0x6367D6059FbBd0c7832847d702588f01c6077cF1
   > block number:        19
   > block timestamp:     1639359583
   > account:             0xF3133758a5b12c99bdF762F93b86239a7Ef167f6
   > balance:             90.95737322
   > gas used:            149715 (0x248d3)
   > gas price:           20 gwei
   > value sent:          2 ETH
   > total cost:          2.0029943 ETH


   > Saving migration to chain.
   > Saving artifacts
   -------------------------------------
   > Total cost:           2.0029943 ETH


4_NonWithdrawContract_migration.js
==================================

   Deploying 'NonWithdrawalContract'
   ---------------------------------
   > transaction hash:    0xdd86457548e6d54915c626d51121e1d8286a9979aedc218540ea4ccf4c6d8959
   > Blocks: 0            Seconds: 0
   > contract address:    0x37892D4862c7Fce93752a5b8af8484dEEBFD3E3E
   > block number:        21
   > block timestamp:     1639359583
   > account:             0xF3133758a5b12c99bdF762F93b86239a7Ef167f6
   > balance:             90.95277612
   > gas used:            202342 (0x31666)
   > gas price:           20 gwei
   > value sent:          0 ETH
   > total cost:          0.00404684 ETH


   > Saving migration to chain.
   > Saving artifacts
   -------------------------------------
   > Total cost:          0.00404684 ETH


Summary
=======
> Total deployments:   4
> Final cost:          4.01550836 ETH

우리가 작성한 3개의 컨트랙트 배포를 정상적으로 마쳤습니다.

각 항목의 contract address가 배포된 컨트랙트 주소입니다.

· 동작 확인하기

동작을 수행하고 확인하는 코드는 test 아래에서 관리합니다.

const PayableContract = artifacts.require("PayableContract");
const NonPayableContract = artifacts.require("NonPayableContract");

const NonWithdrawalContract = artifacts.require("NonWithdrawalContract");
const truffleAssert = require('truffle-assertions');

const web3 = require('web3');

describe("test", function(accounts) {
  
  before(async function () {
    /* 
      배포된 컨트랙트 가져오기, 
      1. 출금패턴 비적용, 
      2. 이더수신 불가능, 
      3. 이더수신 가능 
    */
    this.instance = await NonWithdrawalContract.deployed(); 
    this.nonPayableContract = await NonPayableContract.deployed();
    this.payableContract = await PayableContract.deployed();
  })

  it('최고 전송자, 금액 바뀌는 경우 by PayableContract', async function() {
    const to = NonWithdrawalContract.address;
    const amount = web3.utils.toWei('0.1', 'ether');
    await this.payableContract.send(to, amount); 
    const richest = await this.instance.richest();
    const mostSent = await this.instance.mostSent();
    
    assert.strictEqual(richest, this.payableContract.address);
    assert.strictEqual(mostSent.toString(), amount);
  })
  
  it('최고 전송자가 바뀌지 않는경우 by NonPayableContract', async function() {
    const to = NonWithdrawalContract.address
    const amount = web3.utils.toWei('0.1', 'ether');
  
    await this.nonPayableContract.send(to, amount); 
    
    const richest = await this.instance.richest();
    const mostSent = await this.instance.mostSent();
    
    assert.strictEqual(richest, this.payableContract.address);
    assert.strictEqual(mostSent.toString(), amount);
  })
  
  it('최고 전송자가 바뀌는 경우 by NonPayableContract', async function() {
    const to = NonWithdrawalContract.address
    const amount = web3.utils.toWei('0.2', 'ether');
  
    await this.nonPayableContract.send(to, amount); 
    
    const richest = await this.instance.richest();
    const mostSent = await this.instance.mostSent();
    
    assert.strictEqual(richest, this.nonPayableContract.address);
    assert.strictEqual(mostSent.toString(), amount);
  })

  it('NonPayable -> Payable로 다시 바뀌는 경우: NonPayable에게 NonPayable.transfer 발생: 지금부터 NonWithdrawContract의 mostSent보다 높은 이더 전송시 에러', async function () {
    // 해당 케이스는 반드시 에러가 발생한다.
    // NonWithdrawalContract의 richest는 receive()가 구현되어 있지 않기 때문에 .transfer()가 정상동작 할 수 없다.
    // NonWithdrawalContract 해당 컨트랙트는 richest를 바꿀 수 있는 방법이 없기 때문에 더이상 동작하지 않는 코드가 된다.

    const to = NonWithdrawalContract.address;
    const amount = web3.utils.toWei('0.3', 'ether');
    const tx = this.payableContract.send(to, amount); 

    await truffleAssert.reverts(
      tx,
      'revert'
    );
  })

  it('에러1', async function () {
    const to = NonWithdrawalContract.address;
    const amount = web3.utils.toWei('0.4', 'ether');
    const tx = this.payableContract.send(to, amount); 

    await truffleAssert.reverts(
      tx,
      'revert'
    );
  })
  
  it('에러2', async function () {

    const to = NonWithdrawalContract.address;
    const amount = web3.utils.toWei('0.4', 'ether');
    const tx = this.payableContract.send(to, amount); 

    await truffleAssert.reverts(
      tx,
      'revert'
    );
  })

  it('에러3', async function () {
    const to = NonWithdrawalContract.address;
    const amount = web3.utils.toWei('0.4', 'ether');
    const tx = this.nonPayableContract.send(to, amount); 

    await truffleAssert.reverts(
      tx,
      'revert'
    );
  })
})

동작을 수행하기 위해 test를 이용합니다.

$ truffle test --network dev

Using network 'dev'.

Compiling your contracts...
===========================
> Everything is up to date, there is nothing to compile.

  test
    ✓ 최고 전송자, 금액 바뀌는 경우 by PayableContract (97ms)
    ✓ 최고 전송자가 바뀌지 않는경우 by NonPayableContract (87ms)
    ✓ 최고 전송자가 바뀌는 경우 by NonPayableContract (100ms)
    ✓ NonPayable -> Payable로 다시 바뀌는 경우: NonPayable에게 NonPayable.transfer 발생: 지금부터 NonWithdrawContract의 mostSent보다 높은 이더 전송시 에러 (267ms)
    ✓ 에러1 (66ms)
    ✓ 에러2 (80ms)
    ✓ 에러3 (77ms)

  7 passing (806ms)

여기선 7개의 항목에 대한 동작을 테스트합니다.

각각의 테스트 항목이 무엇을 테스트 하는지 살펴보겠습니다.

before(async function () {
    /* 
      배포된 컨트랙트 가져오기, 
      1. 출금패턴 비적용, 
      2. 이더수신 불가능, 
      3. 이더수신 가능 
    */
    this.instance = await NonWithdrawalContract.deployed(); 
    this.nonPayableContract = await NonPayableContract.deployed();
    this.payableContract = await PayableContract.deployed();
  })

해당 코드는 우리가 배포한 컨트랙트를 가져오는 것을 의미합니다.

▶ PayableOntract가 최고 전송자(richest)가 되는경우

it('최고 전송자, 금액 바뀌는 경우 by PayableContract', async function() {
  const to = NonWithdrawalContract.address;
  const amount = web3.utils.toWei('0.1', 'ether');
  await this.payableContract.send(to, amount); 
  const richest = await this.instance.richest();
  const mostSent = await this.instance.mostSent();
  
  assert.strictEqual(richest, this.payableContract.address);
  assert.strictEqual(mostSent.toString(), amount);
})

payableContract가 최초로 이더를 전송하기 때문에 최고 전송자가 됩니다. 이때 0.1ETH를 전송합니다.

▶ 최고 전송자(richest)가 바뀌지 않는경우

it('최고 전송자가 바뀌지 않는경우 by NonPayableContract', async function() {
  const to = NonWithdrawalContract.address
  const amount = web3.utils.toWei('0.1', 'ether');
  
  await this.nonPayableContract.send(to, amount); 
  
  const richest = await this.instance.richest();
  const mostSent = await this.instance.mostSent();
  
  assert.strictEqual(richest, this.payableContract.address);
  assert.strictEqual(mostSent.toString(), amount);
})

NonPayableContract가 이더를 전송하지만 현재 최고 전송자보다 더 많은 금액을 전송하지 않았기 때문에 바뀌지 않습니다.

▶ NonPayableContract로 최고 전송자(richest)가 바뀌는 경우

it('최고 전송자가 바뀌는 경우 by NonPayableContract', async function() {
  const to = NonWithdrawalContract.address
  const amount = web3.utils.toWei('0.2', 'ether');
  
  await this.nonPayableContract.send(to, amount); 
  
  const richest = await this.instance.richest();
  const mostSent = await this.instance.mostSent();
  
  assert.strictEqual(richest, this.nonPayableContract.address);
  assert.strictEqual(mostSent.toString(), amount);
})

NonPayableContract가 기존 최고 전송자보다 더 많은 ETH를 전송했기 때문에 최고 전송자가 바뀌고 기존 최고 전송자인 PayableContract에게 0.1 이더를 돌려줍니다.

여기선 NonWithdrawalsContract의 richest, mostSent만 검사했지만 PayableContract의 이더리움 잔액도 검사해야 합니다.

(각 컨트랙트의 잔액 조회까지 들어가면 코드량이 너무 증가할 것 같아 PayableContract, NonPayableContract의 이더리움 잔액 조회 코드는 포함하지 않았습니다.)

▶ PayableContract로 최고 전송자가 다시 바뀌는 상황

it('NonPayable -> Payable로 다시 바뀌는 경우: NonPayable에게 NonPayable.transfer 발생: 지금부터 NonWithdrawContract의 mostSent보다 높은 이더 전송시 에러', async function () {
  // 해당 케이스는 반드시 에러가 발생한다.
  // NonWithdrawalContract의 richest는 receive()가 구현되어 있지 않기 때문에 .transfer()가 정상동작 할 수 없다.
  // NonWithdrawalContract 해당 컨트랙트는 richest를 바꿀 수 있는 방법이 없기 때문에 더이상 동작하지 않는 코드가 된다.

  const to = NonWithdrawalContract.address;
  const amount = web3.utils.toWei('0.3', 'ether');
  const tx = this.payableContract.send(to, amount); 

  await truffleAssert.reverts(
    tx,
    'revert'
  );
})

최고 전송자가 바뀌면서 NonPayableContract에게 transfer()로 호출하기 때문에 에러가 발생합니다.

  await truffleAssert.reverts(
    tx,
    'revert'
  );

에러가 발생하는지 검사하는 코드입니다.

▶ 계속 에러 발생

it('에러1', async function () {
    const to = NonWithdrawalContract.address;
    const amount = web3.utils.toWei('0.4', 'ether');
    const tx = this.payableContract.send(to, amount); 

    await truffleAssert.reverts(
      tx,
      'revert'
    );
  })
  
  it('에러2', async function () {

    const to = NonWithdrawalContract.address;
    const amount = web3.utils.toWei('0.4', 'ether');
    const tx = this.payableContract.send(to, amount); 

    await truffleAssert.reverts(
      tx,
      'revert'
    );
  })

  it('에러3', async function () {
    const to = NonWithdrawalContract.address;
    const amount = web3.utils.toWei('0.4', 'ether');
    const tx = this.nonPayableContract.send(to, amount); 

    await truffleAssert.reverts(
      tx,
      'revert'
    );
  })

이후 누가 이더를 전송하든 항상 에러가 발생합니다. 만약, NonWithdrawalsContract가 출금 패턴을 적용했다면 PayableContract가 호출하는 에러1, 에러2는 정상적으로 호출됩니다.

샘플 코드는 깃허브로 공유합니다.

https://github.com/pjt3591oo/truffle-withdrawal-pattern

 

GitHub - pjt3591oo/truffle-withdrawal-pattern

Contribute to pjt3591oo/truffle-withdrawal-pattern development by creating an account on GitHub.

github.com