Handling Multiple Stories

Unit Test가 끝나서 이제 좀 눈에 보이는 웹페이지 작업을 하려나 했는데요. 한 가지 해결하지 않은 부분이 남았습니다.

여태까지 작성한 DreamStory 스마트 컨트랙트를 보면 Story 하나에 대한 내용 뿐입니다. 그러나 원하는 것은 처음에도 보여드렸듯이 여러 개의 Stories가 사용자에게 보여지는 것입니다. 이렇게요.

각각의 DreamStory 컨트랙트는 독립적입니다. 어떤 연결 관계도 없습니다. 이들을 이 웹페이지에서 관리하려면 각 컨트랙트의 주소를 관리해야만 합니다. 이때, 세 가지 중요한 내용을 검토해야 합니다.

① 배포된 각 Story 컨트랙트의 주소를 알아야 하는 점
② 새로운 Story 컨트랙트의 생성 비용 지불 주체를 정해야 하는 점
③ 서비스는 안전해야 하며, 사용자로부터 신뢰를 받아야 하는 점

이러한 문제를 해결하기 위한 솔루션을 몇 가지 살펴보겠습니다.


솔루션1: 사용자가 컨트랙트 배포

다음과 같은 좀 무식한 방법이 있을 수 있습니다.

  • 사용자가 "Create a Story"를 클릭함
  • 웹 관리자가 DreamStory 컨트랙트 소스 코드를 사용자에게 전송함
  • 사용자는 소스 코드로부터 컨트랙트를 배포하고, 컨트랙트 배포 주소를 저장함
  • 사용자는 배포 주소를 웹 관리자에게 전송함
  • 웹 관리자는 전송받은 배포 주소를 웹페이지에 표시함

뭔가 사용자가 해야 할 일이 많아 작업이 번거롭습니다. 웹 관리자와 사용자가 수동으로 해야 할 일이 많습니다.

앞서 언급한 중요한 문제에 대해 살펴보면, 솔루션1은 사용자가 직접 컨트랙트 배포 주소를 획득하여 관리자에 전송하는 방식인데 번거롭습니다. 그리고 2번 컨트랙트 생성 비용과 관련해서 사용자가 지불하게 하는 방식이라 이것은 합당해 보입니다.
가장 문제는 바로 3번 신뢰성입니다. 소스 코드를 사용자에게 전달하여 사용자가 배포하게 하면, 사용자가 소스 코드를 임의로 변경하여 특정 기능을 없애거나 추가하여 배포할 수 있습니다. 이런 경우 심각한 문제가 될 수 있으며, 정상적인 사용자로부터 신뢰를 받기 어렵습니다. 서비스가 제대로 돌아갈 수 없겠죠.

솔루션2: 관리자가 컨트랙트 배포

솔루션1의 가장 큰 문제인 신뢰성 문제를 해결하기 위해 웹 관리자가 컨트랙트를 배포하는 방법이 있습니다.

  • 사용자가 "Create a Story"를 클릭함
  • 웹 관리자가 사용자의 Story에 대한 컨트랙트를 배포하여 주소를 획득함
  • 획득한 주소를 웹페이지에 표시함

솔루션2는 솔루션1과 달리 보안 문제가 적어 보입니다. 왜냐하면 소스 코드가 사용자에게 전혀 전송되지 않아 악의적으로 수정할 수가 없기 때문입니다. 또한 배포된 컨트랙트의 주소도 즉시 알수 있어서 매우 편해 보입니다.

그러나 어떤 문제가 있을까요?

바로 중요한 문제 2번인 컨트랙트 생성 비용의 지불 주체에 관한 것입니다. 웹 관리자 계정이 컨트랙트를 배포하므로, 그 비용을 모두 웹 관리자가 지불해야 하는 문제가 발생합니다. 컨트랙트 개수가 적다면 큰 문제가 아닌 것 처럼 느껴질 수 있으나, 그 개수가 수 만, 수 십만 개가 되면 그 비용은 절대 무시할 수 없을 것입니다.

따라서, 이 방법도 좋은 솔루션은 아닙니다.


솔루션3: 별도의 컨트랙트가 컨트랙트 배포

조금은 생소할 수 있는데, 컨트랙트가 다른 컨트랙트를 배포하는 것이 가능합니다. 또 컨트랙트에서 다른 컨트랙트의 함수를 호출하는 것도 가능하고요. 솔루션3은 이와 같이 별도의 컨트랙트를 만들어서 이용하는 방식입니다. 이 방식은 솔루션1과 솔루션2의 하이브리드 방식입니다.

그림으로 구성해 보면 이렇습니다.

  • 웹관리자가 DreamStory 컨트랙트를 관리하는 DreamFactory 컨트랙트 생성합니다.
  • 사용자는 "Create a DreamStory" 클릭합니다.
  • 이때 사용자에게 트랜잭션 발생 화면을 띄워 비용 지불이 필요함을 알림니다(web3/Metamask).
  • 사용자가 트랜잭션 비용을 지불하면 DreamFactory 컨트랙트가 DreamStory 소스 코드를 이용하여 배포합니다.
  • DreamFactory가 배포된 컨트랙트의 주소를 저장하여 관리합니다.

솔루션3는 컨트랙트 소스 코드를 전달하지 않기 때문에 보안 이슈가 적습니다. 그리고 각각의 DreamStory 컨트랙트는 사용자가 지불하는 방식입니다. 사용자가 새로운 Story를 생성할 때 Metamask와 같은 프로그램에게 트랜잭션 내용을 띄워서 사용자가 지불하게끔 합니다. 웹 관리자가 비용 지불하지 않아도 됩니다. 웹 관리자는 DreamFactory 컨트랙트를 배포할 때 한 번 비용 지불할 뿐입니다. 그리고 DreamFactory 컨트랙트는 생성되는 모든 DreamStory 컨트랙트의 주소를 관리할 수 있습니다.

솔루션3이 최선의 방법이 아닐수도 있습니다만, 중요 문제 세 가지 관점에서는 효율적이고 합리적인 방법이라고 생각됩니다.

그럼 DreamFactory 컨트랙트를 코딩해보겠습니다.



DreamFactory 코딩

솔리디티 파일에는 1개 이상의 컨트랙트가 있을 수 있습니다. C++에서 한 파일에 여러 개의 클래스가 있는 것처럼요. 컨트랙트는 하나의 클래스라고 보면 편합니다. 따라서 다음 DreamFactory 코드를 기존의 DreamStory가 저장된 파일에 추가하면 됩니다. 딱 한 가지 변경이 필요합니다. DreamStory 생성자를 다음과 같이 변경해야 합니다. 이유는 DreamFactory에서 DreamStory를 생성한 사용자의 계정을 넘겨줘야 하기 때문입니다.

    /*
     * Constructor
     @param min_down_price minimum download price in wei
     @param creator address of the creator of this story
     */
    function DreamStory( uint _min_down_price, address _creator ) public {
        // set author to message sender who is the creator of this dream story
        author= _creator;
        // set minimum download price
        min_down_price_wei= _min_down_price;
    }

마지막으로 파일 이름을 DreamFactory.sol로 바꿔주는게 좀 더 알아보기 쉽겠죠?


DreamFactory의 기능

  • deployed_dream_stories: 배포된 모든 DreamStory 컨트랙트 주소 배열
  • createDreamStory: DreamStory 인스턴스를 배포하고 배포된 컨트랙트 주소를 deployed_dream_stories에 저장
  • getDeployedDreamStories: 배포된 모든 DreamStory 컨트랙트 리스트를 반환

밑의 테스트를 위해 소스 코드 전체를 표시했습니다. 그리고 시리즈 진행하면서 컨트랙트 수정을 가했는데, 이것이 현시점의 최종 버전입니다.
솔리디티 버전 업에 따라 아래와 같은 Warning도 마져 반영하여 수정했습니다. 컨트랙트의 생성자로 constructor라는 이름을 사용하라는 것입니다.


// filename: DreamFactory.sol
pragma solidity ^0.4.17;

// dream story factory contract
contract DreamFactory {
    // array of addresses of deployed dream stories
    address[] public deployed_dream_stories;
    /*
     * Create a new dream story
     * @param min_down_price minimum download price in wei
     */
    function createDreamStory( uint _min_down_price ) public {
        // create a new dream story
        address new_story= new DreamStory( _min_down_price, msg.sender );
        // save the deployed address
        deployed_dream_stories.push( new_story );
    }

    /*
     * Get the deployed dream stories
     * @return addresses of the deployed dream stories
     */
    function getDeployedDreamStories() public view returns (address[]) {
        return deployed_dream_stories;
    }
}

// dream story contract
contract DreamStory {
    //// state variables
    // download struct
    struct Download {
        // address of the downloder
        address downloader;
        // download price in wei
        uint price_wei;
        // download date
        uint date;
    }
    // download history
    Download[] public downloads;
    // author of a dream story
    address public author;
    // list of contributors as mapping
    mapping( address => bool ) public contributors;
    // number of votes which is the number of contributors
    uint public votes_count;
    // list of approvers
    mapping( address => bool ) public approvers;
    // number of approvers
    uint public approvers_count;
    // minimum download price in wei
    uint public min_down_price_wei;
    // list of downloaders as mapping
    mapping( address => bool ) public downloaders;

    //// modifier
    // only for author
    modifier onlyAuthor() {
        require( msg.sender == author );
        _;
    }
    // only for contributors
    modifier onlyContributor() {
        // the sender should be in the contributors list
        require( contributors[msg.sender] );
        _;
    }

    //// functions
    /*
     * Constructor
     @param min_down_price minimum download price in wei
     @param creator address of the creator of this story
     */
    constructor( uint _min_down_price, address _creator ) public {
        // set author to message sender who is the creator of this dream story
        author= _creator;
        // set minimum download price
        min_down_price_wei= _min_down_price;
    }

    /*
     * A contributor donates some money for a dream story.
     * So the money will be transfered to this contract address, resulted in increasing the balance
     * @note this function can receive some money, which is msg.value
     */
    function contribute() public payable {
        // check if the money is greater than zero
        require( msg.value > 0 );
        // increase the vote counts
        votes_count++;
        // set contributor address to true
        contributors[ msg.sender ]= true;
    }

    /*
     * Download (license of) the dream story
     * @note this function can receive some money, which is msg.value
     */
    function download() public payable onlyContributor {
        // check if the contributor has downloaded before.
        // if so, no need to download again
        require( !downloaders[msg.sender] );
        // check if the input price is bigger than the min_down_price_wei
        require( msg.value >= min_down_price_wei );
        // local variable is basically stored in storage,
        // and literal such as struct is created in memory since it is temporary.
        // storage variable references the original variable
        // memory variable copies the original variable
        // memory is temporary, storage is global.
        Download memory new_download= Download({
           downloader: msg.sender,
           price_wei: msg.value,
           date: now
        });

        // add it to the downloads array
        downloads.push( new_download );
        // set the download address to true
        downloaders[ msg.sender ]= true;
    }


    /*
     * Approve the payment to the author by a contributor
     * @note a contributor who already approved the withdrawl cannot call it again
     */
    function approveWithdrawal() public onlyContributor {
        // check whether this contributor approved the withdrawl already
        require( !approvers[msg.sender] );
        // set the account as an approver
        approvers[ msg.sender ]= true;
        // increase the counts
        approvers_count++;
    }

    /*
     * Execute the withdrawal by the author
     */
    function executeWithdrawal() public onlyAuthor {
        // half of contributors should approve the withdrawal
        require( approvers_count > ( votes_count/2 ) );
        // transfer the balance to the author
        author.transfer( address(this).balance );
    }

    /*
     * Get summary of the dream story
     * @return
     */
    function getSummary() public view returns ( uint, uint, uint, uint, uint, address )
    {
        return (
          address(this).balance,
          votes_count,
          downloads.length,
          min_down_price_wei,
          approvers_count,
          author
        );
    }
}

compile.js 수정

파일 이름을 DreamFactory.sol로 바꿨고, 하나의 파일에 2개의 컨트랙트가 있어 compile.js를 수정한 후 다시 컴파일해야 합니다.

// get the contract file path
const contract_path= path.resolve( __dirname, 'contracts', 'DreamFactory.sol' );
...
// compiled output, extract only contracts part
console.log( 'now compiling the contract code ...' );
const compile_output= solc.compile( contract_src, 1 ).contracts;
assert( compile_output[':DreamFactory'] );
assert( compile_output[':DreamStory'] );
console.log( 'compiled' );


다음과 같이 compile.js를 실행하면 DreamFactory.json파일과 DreamStory.json파일 2개가 만들어집니다.

$ cd ethereum
$ node compile.js


DreamFactory 테스트

DreamFactory를 테스트하기 위해 별도의 Unit Test는 진행하지 않겠습니다. 코드가 간단하여 오히려 Remix에서 테스트하는 것이 빠르고 편합니다. 위 소스 코드를 Remix로 복사합니다. 컴파일 후, Run 탭으로 이동하여 다음과 같이 DreamFactory를 선택한 후 Deploy를 클릭합니다. 이때 다음과 같이 JavaScript VM과 적절한 Gas Limit 값이 설정되어 있어야 합니다. 그리고 DreamFactory를 배포하는 계정도 기억해 두세요.


배포가 완료가 되면 다음과 같이 두 번째 계정을 선택하고 "createDreamStory"를 클릭합니다. 이때, 인자로 100을 입력합니다. 그 다음으로 getDeployedDreamStories를 클릭하면 배포된 컨트랙트 주소가 표시됩니다.


이 주소를 이용하여 컨트랙트에 접속할 수가 있습니다. 이것을 일단 복사해 둡니다. 우리가 접속하려는 컨트랙트는 DreamFactory가 배포한 DreamStory 컨트랙트이기 때문에 탭에서 DreamStory를 선택합니다. 그리고 복사한 주소를 Remix의 At Address에 입력합니다. 그러면 배포된 DreamStory 컨트랙트와 인터페이스를 할 수 있습니다. 이때 다음과 같이 author를 클릭하면 두 번째 계정이 표시됩니다. 이것은 DreamStory 컨트랙트를 생성할 때 사용한 계정입니다.



이로써 드디어 DreamFactory, DreamStory 컨트랙트 코딩 및 테스트가 끝났습니다. 글을 하나 작성할 때마다 직접 코딩하고 테스트하다 보니 변경한 것이 많았습니다.

다음은 웹페이지를 작성하기 전에 컨트랙트 배포를 자동으로 하는 스크립트를 만들겠습니다. 특히 이때는 로컬 테스트 네트워크를 사용하지 않고 Rinkeby 테스트 네트워크에 배포하는 스크립트를 추가하려고 합니다.

저도 작성한 컨트랙트를 테스트 네트워크에 올리는 것이 처음이라 조금 설레네요~ 테스트 네트워크라지만, 실제로 여러 사람이 접속하는 네트워크니까요~


오늘의 실습: 솔루션3의 단점은 뭐가 있을까요? 또 솔루션3보다 더 좋은 솔루션을 생각해 보세요.