[DreamChain DApp] #12 Handling Multiple Stories
Unit Test가 끝나서 이제 좀 눈에 보이는 웹페이지 작업을 하려나 했는데요.. 한가지 해결하지 않은 부분이 남았습니다.
이전글 - [DreamChain DApp] #11 Smart Contract Unit Test 4
본 내용은 Ethereum and Solidity: The Complete Developer's Guide을 참고해서 작성되었습니다.
여태까지 작성한 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이 최선의 방법이 아닐수도 있습니다만, 중요 문제 3가지 관점에서는 효율적이고 합리적인 방법이라고 생각됩니다.
그럼 DreamFactory 컨트랙트를 코딩해보겠습니다.
DreamFactory 코딩
솔리디티 파일에는 한 개 이상의 컨트랙트가 존재하는 것이 가능합니다. 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
로 바꿨고, 하나의 파일에 두개의 컨트랙트가 있게 되어 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를 클릭해보면, 두번째 계정이 표시됩니다. 즉, 우리가 컨트랙트를 생성할 때 사용한 계정입니다.
이로써 드디어 DreamFactory, DreamStory 컨트랙트 코딩 및 테스트가 끝났습니다. 글을 하나 작성할 때마다 직접 코딩하고 테스트하다 보니 변경점이 다수 나왔습니다. 시리즈 글을 따라오시는 분들과 응원해주시는 분들께 고마움을 전합니다.
다음은 웹페이지를 작성하기 전에 컨트랙트 배포를 자동으로 하는 스크립트를 만들어 보겠습니다. 특히 이때는 로컬 테스트 네트워크를 사용하지 않고 Rinkeby 테스트 네트워크에 배포하는 스크립트를 추가하려고 합니다.
저도 작성한 컨트랙트를 테스트 네트워크에 올리게 되는 것이 처음이라 조금 설레네요~ 테스트 네트워크라지만, 실제로 여러 사람이 접속하는 네트워크니까요~
오늘의 실습: 솔루션3의 단점은 뭐가 있을까요? 또 솔루션3보다 더 좋은 솔루션을 생각해 보세요.
(jjangjjangman 태그 사용시 댓글을 남깁니다.)
[제 0회 짱짱맨배 42일장]4주차 보상글추천, 1,2,3주차 보상지급을 발표합니다.(계속 리스팅 할 예정)
https://steemit.com/kr/@virus707/0-42-4-1-2-3
4주차에 도전하세요
그리고 즐거운 스티밋하세요!