소스 코드, 스크립트 정리

잠시 쉬어가는 의미에서 정리를 하고 다음으로 넘어가겠습니다.

지금까지 작업한 내용을 정리해 보겠습니다. 또 수정한 내용도 모두 반영하여 깔끔하게 표시해 보겠습니다.

만들고 있는 DApp은 여러 사용자들이 꿈 일기를 공유하는 앱입니다. 또 보팅도 하고, 저작권 같은 것도 구매하는 그런 단순한 앱입니다. 결과적으로 다음 그림과 같은 것들이 표시되고 서비스될 것입니다.


1. 프로젝트 디렉터리 구성

폴더 구성은 다음과 같습니다. 웹페이지를 만들기 위한 components, pages 등은 아직 미구현 상태입니다.


개발 에디터(atom)에서 폴더 한단계 더 들어가 보면 다음과 같습니다.


그리고 사용된 패키지 정보들은 package.json에서 볼 수 있습니다. 이것을 표시하는 이유는 블록체인 관련 개발은 빠르게 변화하고 있어서 패키지의 버전에 따라 코드가 동작하지 않을 수 있기 때문입니다.


2. DreamStory, DreamFactory 컨트랙트 

  • 파일명: DreamFactory.sol
    꿈 일기 서비스의 토대를 이루는 컨트랙트 2개를 포함한 소스 파일입니다.
  • DreamStory: 하나의 꿈 일기에 대한 내용 및 기타 정보를 관리하는 컨트랙트
  • DreamFactory: 꿈 일기 작성자 대신 DreamStory 컨트랙트를 배포하는 컨트랙트

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
        );
    }
}


3. 컴파일 스크립트

  • 파일명: compile.js

컴파일 스크립트를 만든 이유는 크게 두 가지 입니다. 컨트랙트 컴파일의 수동 반복 작업을 줄이고, Unit Test를 위해 한 번만 컴파일하여 빌드 파일을 사용해서 반복된 컴파일을 제거하는 것입니다.

컴파일 스크립트를 만들면 매번 수동으로 해줘야 하는 일을 안해도 되니 매우 편리합니다.

// @note: this compile.js will be usually executed once unless the contract file is changed.
// path module to read soliditi files
const path= require('path');
// solidity compiler module
const solc= require('solc');
// file system extra functionality
const fs= require('fs-extra');
// import assert module to test with assertion
const assert= require( 'assert' );

// get the build directory using the path module
// build directory is used to re-use the compiled code when no change occurs
// __dirname will indicate the root directory where the oxmpile.js occurs
const build_path= path.resolve( __dirname, 'build' );
// delete the build directory
fs.removeSync( build_path );

// get the contract file path
const contract_path= path.resolve( __dirname, 'contracts', 'DreamFactory.sol' );
// read the contract file
const contract_src= fs.readFileSync( contract_path, 'utf8' );

// 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' );

// create the build directory
fs.ensureDirSync( build_path );

// create build files looping over contracts
for( let contract in compile_output ) {
  // create a contract json file
  fs.outputJsonSync(
    path.resolve( build_path, contract.replace(':', '') + '.json' ),
    compile_output[ contract ]
  );
}


4. Unit Test 스크립트

  • 파일명: dreamstory.test.js

코딩에 있어서 테스트는 기본입니다. 사실 저도 테스트를 안 하는 경우가 많습니다. 그러나 컨트랙트의 코딩의 경우 특히 테스트가 절대적으로 필요합니다. 왜냐하면 컨트랙트 코드는 중요한 자산, 정보가 오고가기 때문입니다. 실수나 오류가 허용되는 레벨이 아닙니다. 지금 개발하는 DApp이 정식 서비스를 목적으로 하는 것은 아니지만, 테스트 습관을 가지면 좋습니다. 또 테스트하는 환경구축도 제대로 하면 더욱 편리하구요~

// import assert module to test with assertion
const assert= require( 'assert' );
// local ethereum test network
const ganache= require( 'ganache-cli' );
// web3 constructor function. note that it is Web3 not web3
const Web3= require( 'web3' );
// create web3 instance to connect local test network
const web3= new Web3( ganache.provider() );
// bytecode and api (interface) of compiled contract
const compiled_contract= require( '../ethereum/build/DreamStory.json' );

//// global variables
// ethereum accounts
let accounts;
// deployed contract
let dream_story;

//// intial values for tests
// initial minimum download price in wei
const INIT_MIN_DONW_PRICE= 100;

// setup code before running a test
beforeEach( async () => {
  // get all the accounts
  accounts= await web3.eth.getAccounts();

  // create a contract instance with arguments and deploy it
  // parse the json interface, so the javascript object can be used for contract
  dream_story= await new web3.eth.Contract( JSON.parse( compiled_contract.interface ) )
    // tell web3 that we want to deploy a new copy of the contract.
    // do not forget about the arguments that the constructor of the contract requires
    // calling deploy does not deploy the contract, it creates an object to be deployed
    .deploy({ data: compiled_contract.bytecode, arguments: [INIT_MIN_DONW_PRICE] })
    // send transaction that creates the contract
    .send( { from: accounts[0], gas: '1000000' } )
});


// test groups: test contract basics
describe( 'DreamStory Contract Basics', () => {
  // unit test: simply print out the deployed contract object
  it( 'Deploy a DreamStory contract', () => {
    // print out deployed contract object
    console.log( dream_story );
  });
  // unit test: contract address exists
  it( 'Deployed contract address', () => {
    // use assert.ok function to check if the address exists
    assert.ok( dream_story.options.address );
    // print out the address
    console.log( dream_story.options.address );
  });
  // unit test: check if the minimum download price is the same as the input
  it( 'Check minimum download price', async () => {
    // get the minimum_down_price_wei of the contract
    // this uses await since it needs to connect the Contract
    // to get the value of state variable, need to use call() function
    const min_down_price_wei= await dream_story.methods.min_down_price_wei().call();
    // check if the state variable min_down_price_wei is equal to the intial value
    assert.equal( min_down_price_wei, INIT_MIN_DONW_PRICE );
    // print out the min_down_price_wei
    console.log( min_down_price_wei );
  });
});

// test contribution value in ether
const INIT_CONTRIBUTE_ETH= '1';
// test groups: contribute tests
describe( 'DreamStory contribute function tests', () => {
  // unit test: check if the votes_count increase when a user contributes
  //            now the user should be inserted to contributor list
  //            the balance of the contract should increase
  it( 'Check votes count and others', async () => {
    // get the intial balance of the contract to test balance, which is in string
    let init_balance= await web3.eth.getBalance( dream_story.options.address );
    // convert the wei into ether
    init_balance= web3.utils.fromWei( init_balance, 'ether' );
    // cast the string to float
    init_balance= parseFloat( init_balance );
    // use the second account to contribute
    await dream_story.methods.contribute().send( {
      from: accounts[1],
      // amount to contribute. convert the ether into wei
      value: web3.utils.toWei( INIT_CONTRIBUTE_ETH, 'ether' ),
      // set gas limit`
      gas: '1000000'
    });
    // get the votes_count of the deployed contract
    const votes_count= await dream_story.methods.votes_count().call();
    // check if the votes_count increased
    assert.equal( votes_count, 1 );
    // print out the votes_count
    console.log( votes_count );
    // get the mapping value of the account
    const is_contributor= await dream_story.methods.contributors( accounts[1] ).call();
    // assert the result
    assert( is_contributor );
    console.log( is_contributor );
    // get the balance of the author now. note that the balance is in string
    let balance= await web3.eth.getBalance( dream_story.options.address );
    // first convert the balance into ether
    balance= web3.utils.fromWei( balance, 'ether' );
    // cast the string to float
    balance= parseFloat( balance );
    console.log( init_balance );
    console.log( balance );
    // check if it is increased
    assert( balance > init_balance );
  });
});

// test download value in ether
const INIT_DOWNLOAD_ETH= '2';
// test groups: download tests
describe( 'DreamStory download function tests', () => {
  //// test: try to download using a not contributor's account
  it( 'Downloader test', async () => {
    // call download using account[2] which is not a contributor
    try {
      await dream_story.methods.download().send( {
        from: accounts[2],
        // amount to download. convert ether to wei
        value: web3.utils.toWei( INIT_DOWNLOAD_ETH, 'ether' ),
        // gas limit to execute this function
        gas: 1000000
      });
      assert( false );
    }
    catch( error ) {
      // if error occurs, this assert will pass, which is intended
      assert( error );
      console.log( 'true' );
    }
  });

  ////// tests: increment of the number of downloads
  //            downloaders array
  //            double download
  //            minimum download price
  it( 'Check downloads count and others', async () => {
    // get the intial balance of the contract to test balance
    let init_balance= await web3.eth.getBalance( dream_story.options.address );
    // convert to ether
    init_balance= web3.utils.fromWei( init_balance, 'ether' );
    // cast string to float
    init_balance= parseFloat( init_balance );
    // print out initial balance
    console.log( init_balance );
    // use the second account to contribute
    await dream_story.methods.contribute().send( {
      from: accounts[1],
      // amount to contribute. convert the ether into wei
      value: web3.utils.toWei( INIT_CONTRIBUTE_ETH, 'ether' ),
      // gas limit to execute this function
      gas: 1000000
    });

    //// test: increment of the number of downloads
    // call download using a contributor's account
    await dream_story.methods.download().send( {
      from: accounts[1],
      // amount to download. convert ether to wei
      value: web3.utils.toWei( INIT_DOWNLOAD_ETH, 'ether' ),
      // gas limit to execute this function
      gas: 1000000
    });
    // get the summary
    const summary= await dream_story.methods.getSummary().call();
    // check if the number downloads is increased
    assert.equal( summary[2], 1 );
    // print out the votes_count
    console.log( summary[2] );

    //// test: downloader check
    // get the mapping value of the account
    const is_downloader= await dream_story.methods.downloaders( accounts[1] ).call();
    // assert the result
    assert( is_downloader );
    console.log( is_downloader );

    //// test: block double downloading
    // call download using the address of a downloader
    try {
      await dream_story.methods.download().send( {
        from: accounts[1],
        // amount to download. convert ether to wei
        value: web3.utils.toWei( INIT_DOWNLOAD_ETH, 'ether' ),
        // gas limit to execute this function
        gas: 1000000
      });
      assert( false );
    }
    catch( error ) {
      // if error occurs, this assert will pass, which is intended
      assert( error );
      console.log( 'true' );
    }

    //// test: minimum download price`
    // call download with less amount of money than the miminum
    try {
      const min_down_price= await dream_story.methods.min_down_price_wei().call();
      const down_price= min_down_price - 10;
      await dream_story.methods.download().send( {
        from: accounts[1],
        // amount to download. convert ether to wei
        value: down_price,
        // gas limit to execute this function
        gas: 1000000
      });
      assert( false );
    }
    catch( error ) {
      // if error occurs, this assert will pass, which is intended
      assert( error );
      console.log( 'true' );
    }
  });
});

// test groups: approveWithdrawal tests
describe( 'DreamStory approveWithdrawal function tests', () => {
  //// test: try to approve using a not contributor's account
  it( 'approver test', async () => {
    // call approveWithdrawal using account[2] which is not a contributor
    try {
      await dream_story.methods.approveWithdrawal().send( {
        from: accounts[2],
        // gas limit to execute this function
        gas: 1000000
      });
      assert( false );
    }
    catch( error ) {
      // if error occurs, this assert will pass, which is intended
      assert( error );
      console.log( 'true' );
    }
  });

  ////// tests: increment of the number of approvers
  //            approvers list
  //            double approvals
  it( 'Check approvers count and others', async () => {
    // use the second account to contribute
    await dream_story.methods.contribute().send( {
      from: accounts[1],
      // amount to contribute. convert the ether into wei
      value: web3.utils.toWei( INIT_CONTRIBUTE_ETH, 'ether' ),
      // gas limit to execute this function
      gas: 1000000
    });

    //// test: increment of the number of approvers
    // call approveWithdrawal using a contributor's account
    await dream_story.methods.approveWithdrawal().send( {
      from: accounts[1],
      // gas limit to execute this function
      gas: 1000000
    });
    // get the summary
    const summary= await dream_story.methods.getSummary().call();
    // check if the number of approvers is increased
    assert.equal( summary[4], 1 );
    // print out the approvers_count
    console.log( summary[4] );

    //// test: approver check
    // get the mapping value of the account
    const is_approver= await dream_story.methods.approvers( accounts[1] ).call();
    // assert the result
    assert( is_approver );
    console.log( is_approver );

    //// test: block double approvals
    // call approveWithdrawal using the approver's account
    try {
      await dream_story.methods.approveWithdrawal().send( {
        from: accounts[1],
        // gas limit to execute this function
        gas: 1000000
      });
      assert( false );
    }
    catch( error ) {
      // if error occurs, this assert will pass, which is intended
      assert( error );
      console.log( 'true' );
    }
  });
});

// test groups: executeWithdrawal tests
describe( 'DreamStory executeWithdrawal function tests', () => {
  //// test: try to execute the withdrawal using no author's account
  it( 'author test', async () => {
    // call executeWithdrawal using account[2] which is not the author
    try {
      await dream_story.methods.executeWithdrawal().send( {
        from: accounts[2],
        // gas limit to execute this function
        gas: 1000000
      });
      assert( false );
    }
    catch( error ) {
      // if error occurs, this assert will pass, which is intended
      assert( error );
      console.log( 'true' );
    }
  });

  ////// tests: not enough approvals
  //            contract's balance tranfer
  it( 'Check number of approvals and balance transfer', async () => {
    // get the intial balance of the author, which is accounts[0]
    let init_balance= await web3.eth.getBalance( accounts[0] );
    // convert to ether
    init_balance= web3.utils.fromWei( init_balance, 'ether' );
    // cast string to float
    init_balance= parseFloat( init_balance );

    // use the second account to contribute
    await dream_story.methods.contribute().send( {
      from: accounts[1],
      // amount to contribute. convert the ether into wei
      value: web3.utils.toWei( INIT_CONTRIBUTE_ETH, 'ether' ),
      // gas limit to execute this function
      gas: 1000000
    });

    // approve by the second account
    await dream_story.methods.approveWithdrawal().send( {
      from: accounts[1],
      // gas limit to execute this function
      gas: 1000000
    });

    // use the third account to contribute
    await dream_story.methods.contribute().send( {
      from: accounts[2],
      // amount to contribute. convert the ether into wei
      value: web3.utils.toWei( INIT_CONTRIBUTE_ETH, 'ether' ),
      // gas limit to execute this function
      gas: 1000000
    });
    // no approval by the third account

    // get the summary
    const summary= await dream_story.methods.getSummary().call();
    // print out the votes_count
    console.log( summary[1] );
    // print out the approvers_count
    console.log( summary[4] );

    //// test: not enough number of approvals
    // call executeWithdrawal using the author account
    try {
      await dream_story.methods.executeWithdrawal().send( {
        from: accounts[0],
        // gas limit to execute this function
        gas: 1000000
      });
      assert( false );
    }
    catch( error ) {
      // if error occurs, this assert will pass, which is intended
      assert( error );
      console.log( 'true' );
    }

    //// test: balance transfer
    // approve by the thirde account
    await dream_story.methods.approveWithdrawal().send( {
      from: accounts[2],
      // gas limit to execute this function
      gas: 1000000
    });
    // get the contract balance before withdrawal
    const contract_balance= await web3.eth.getBalance( dream_story.options.address );
    // execute the withdrawal by the author
    await dream_story.methods.executeWithdrawal().send( {
      from: accounts[0],
      // gas limit to execute this function
      gas: 1000000
    });
    // get the balance of the author, which is accounts[0]
    let balance= await web3.eth.getBalance( accounts[0] );
    // convert to ether
    balance= web3.utils.fromWei( balance, 'ether' );
    // cast string to float
    balance= parseFloat( balance );
    assert( balance > init_balance );
    // print out contract's balance before withdrawal
    console.log( web3.utils.fromWei( contract_balance, 'ether' ) );
    // print out initial balance
    console.log( init_balance );
    // print out the balance
    console.log( balance );
  });
});


5. 컨트랙트 배포 스크립트

  • 파일명: deploy.js

deploy 스크립트의 경우에도 Remix에서 컨트랙트 배포가 가능하지만, 보다 빠르고 편리하게 사용할 수 있도록 스크립트로 만들었습니다. 특히나 특정 네트워크에 접속하기 위해 Provider 설정을 보다 편하게 하는 장점이 있습니다.

// wallet provider module
const HDWalletProvider= require( 'truffle-hdwallet-provider' );
// Web3 constructor function
const Web3= require( 'web3' );
// get the compiled contract of DreamFactory, which will be deployed
const factory_contract= require( './build/DreamFactory.json' );
// create a provider
const provider= new HDWalletProvider(
  // put your seed words from metamask
  'social universe put your seed words slush salmon trade dynamic runway other',
  // put your infura api key for rinkeby
  'https://rinkeby.infura.io/v3/your_api_key'
);
// get the web3 instance using the provider
const web3= new Web3( provider );
// to use async functionality, it should be inside a function
// async function means the function runs asynchronously.
// like it runs separately from the main event loop. no sync with the main event loop.
// sync function means the function holds the event loop to sync
const deploy= async () => {
  // get all accounts generated from the seed words
  // await means it waits for the result since the handling smart contract takes time.
  const accounts= await web3.eth.getAccounts();

  console.log( 'balance of accounts[0]: ', await web3.eth.getBalance( accounts[0] ) );

  // console log for deployment. use use the first account to deploy the contract
  console.log( 'Attempting to deploy from account', accounts[0] );
  // create a new contract instance and deploy it
  // web3 does know not about json file but javascript object, so need to parse the json file
  const deployed_factory= await new web3.eth.Contract(
    JSON.parse( factory_contract.interface )
  )
    // deploy the contract using the bytecode
    .deploy( { data: '0x' + factory_contract.bytecode } )
    // use the accounts[0] to execute the deployment
    .send( { from: accounts[0] } );
  // consonle log for the deployed contract address
  console.log( 'DreamFactory contract deployed to', deployed_factory.options.address );
};

// now call the deploy function to deploy the contract
deploy();


6. 컨트랙트 배포

모든 작업을 마쳤다면 최종적으로 컨트랙트를 테스트넷에 배포합니다. 여기서는 Rinkeby 테스트넷에 배포합니다. 한 가지 주의할 점은 배포하는 컨트랙트는 DreamFactory 하나입니다. DreamStory 컨트랙트는 배포하지 않습니다. 이것은 DreamFactory 컨트랙트가 사용자의 배포 비용을 요청하여 배포하도록 되어 있습니다.

배포하면 아래와 같이 http://rinkeby.etherscan.io 웹페이지에서 트랜잭션들을 모두 볼 수 있습니다.


지금까지가 백엔드Back-end에서 돌아가는 스마트 컨트랙트에 관한 부분입니다. 남은 것은 Front-end에서 돌아가는 웹페이지 작업입니다. 아마도 지금보다 더 많은 작업을 해야 할 것입니다. 그래도 웹페이지는 바로 바로 코딩한 내용을 확인할 수 있어서 더욱 재밌을 것입니다.

그럼 다음 장에서 만나요~



오늘의 실습: 자신만의 아이디어를 스마트 컨트랙트로 만들어서 테스트넷에 올려보세요.