[DreamChain DApp] #14 소스코드, 스크립트 정리
잠시 쉬어가는 의미에서 정리를 하고 다음으로 넘어가겠습니다.
이전글 - [DreamChain DApp] #13 Rinkeby 테스트 네트워크 Deploy 스크립트 만들기 2
본 내용은 Ethereum and Solidity: The Complete Developer's Guide을 참고해서 작성되었습니다.
지금까지 작업한 내용을 정리해 보겠습니다. 또 수정한 내용도 모두 반영하여 깔끔하게 표시해 보겠습니다.
만들고 있는 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 olympic stem habit chest slush salmon trade dynamic runway other',
// put your infura api key for rinkeby
'https://rinkeby.infura.io/v3/6ee87ef7d0d448daba84942b01eeb420'
);
// 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에서 돌아가는 웹페이지 작업입니다. 아마도 지금보다 더 많은 작업을 해야 할 것입니다. 그래도 웹페이지는 바로 바로 코딩한 내용을 확인할 수 있어서 더욱 재밌게 할 수 있을 것입니다.
그럼 다음 글에서 만나요~
오늘의 실습: 자신만의 아이디어를 스마트 컨트랙트로 만들어서 테스트넷에 올려보세요.