먼저 truffle-config.js 파일을 열어서 다음 부분을 uncomment하고 수정한다.
networks: {
development: {
host: "127.0.0.1", // Localhost (default: none)
port: 8545, // Standard Ethereum port (default: none)
network_id: "*", // Any network (default: none)
},
...
// Configure your compilers
compilers: {
solc: {
version: "0.8.0", // Fetch exact version from solc-bin (default: truffle's version)
settings: { // See the solidity docs for advice about optimization and evmVersion
optimizer: {
enabled: false,
runs: 200
},
}
},
},
Step #2 토큰 컨트랙트 생성
은행에서 이자로 배포할 토큰은 ERC20 규약을 따르도록 할 것이다. Openzeppelin 라이브러리를 이용해서 이를 처리하도록 한다. 이에 대한 문서는 아래 링크에 있다.
먼저 openzeppelin library를 설치한다. 해당 라이브러리에는 ERC20토큰에 대한 인터페이스가 정의되어 있다. 이를 import하여 상속받아 사용할 것이다.
npm install @openzeppelin/contracts
새로 생성할 토큰에 대한 solidity 파일을 contract/ 밑에 생성하고(contract/Token.sol) 아래 내용을 입력한다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract Token is ERC20 {
address public minter; // 화폐 주조자 라는 뜻이다.
// Event
event MinterChanged(address indexed from, address to);
/**
* @dev 토큰의 이름과 심볼을 ERC20 constructor를 이용해 등록하고, minter를 저장한다.
*
* 토큰의 이름, 심볼, minter 모두 변경할 수 없다. constructor는 단 한번만 실행된다.
*/
constructor() payable ERC20("Decentralized Bank Token", "DEBT") {
minter = msg.sender; // deployer가 처음에는 minter로 등록된다.
}
/**
* @dev 등록된 minter를 {newMinter}로 바꾸는 함수이다.
* 토큰이 생성된 후 은행을 새로운 minter로 바꾼다.
*/
function passMinterRole(address newMinter) public returns (bool) {
// deployer만 이 함수를 실행할 수 있다.
require(
msg.sender == minter,
"Error, only owner can change pass minter role"
);
minter = newMinter;
// Event를 발생시킨다.
emit MinterChanged(msg.sender, newMinter);
return true;
}
/**
* @dev Token을 발행하는 함수
*/
function mint(address account, uint256 amount) public {
// minter만 이 함수를 실행할 수 있다.
require(
msg.sender == minter,
"Error, msg.sender does not have minter role"
);
// ERC20에 정의되어 있다.
_mint(account, amount);
}
}
Solidity 개발 관습에 맞게 주석을 달아 두었다. 토큰을 위한 코드는 생각보다 간단하다. 이제 truffle로 해당 코드를 컴파일해보자. truffle-config.js 에 따로 설정하지 않았다면 컴파일 결과물은 build/contracts 에 생성된다.
truffle compile
Step #3 Test our token contract
Token 컨트랙트가 컴파일 되었으니, 문제 없이 동작하는 지 테스트해보도록 한다. 컨트랙트는 한번 배포되면 수정할 수 없으니 다른 프로그래밍에 비해 test과정이 특히 중요하다.
Truffle의 test과정에서 Ganache를 이용하는 것이 좋다. Ganache는 이더리움 로컬 네트워크를 생성하고 100ETH를 가진 10개의 account를 제공해준다.
Test를 진행할 때 사용할 수 있는 여러 assertion module이 있는 데, 이 중 CryptoZombies 튜토리얼에서 쓰였던 chai를 이용할 것 이다. chai는 아래와 같이 다운로드할 수 있다.
npm install chai
npm i chai-as-promised
이제 test/ 폴더에 test.js 파일과 helpers/time.js helpers/utils.js 를 생성한다.
먼저 test.js 파일에 아래 내용을 입력한다. 설명은 주석을 참고하자.
const Token = artifacts.require('./Token')
const utils = require("./helpers/utils");
const time = require("./helpers/time");
require('chai')
.use(require('chai-as-promised'))
.should()
contract('DeBank', ([deployer, user]) => {
let token
const interestPerSecond = 31668017 //(10% APY) for min. deposit (0.01 ETH)
// beforEach hook은 테스트 전에 매번 실행되는 함수이다.
beforeEach(async () => {
token = await Token.new()
})
// context는 testing group 같은 느낌
context('testing token contract...', () => {
context('success', () => {
// it은 테스트 최소 단위이다.
it('checking token name', async () => {
// 아래와 같이 글로 읽히는 assertion module이 chai
expect(await token.name()).to.be.eq('Decentralized Bank Token')
})
it('checking token symbol', async () => {
expect(await token.symbol()).to.be.eq('DEBT')
})
it('checking token initial total supply', async () => {
expect(Number(await token.totalSupply())).to.eq(0)
})
// xit은 실행되지 않고 pending된다. 아직 DeBank를 구현 안했으니 패스
xit('DeBank should have Token minter role', async () => {
// ...
})
})
// xcontext도 역시 pending된다.
xcontext('failure', () => {
it('passing minter role should be rejected', async () => {
// ...
})
it('tokens minting should be rejected', async () => {
// ...
})
})
})
xcontext('testing deposit...', () => {
// ...
})
xcontext('testing withdraw...', () => {
})
xcontext('testing borrow...', () => {
})
xcontext('testing payOff...', () => {
})
})
다음은 utils.js 파일이다. 자주 사용하는 기능을 따로 구현한 것이다.
const ETHER_ADDRESS = '0x0000000000000000000000000000000000000000'
const EVM_REVERT = 'VM Exception while processing transaction: revert'
const ether = n => {
return new web3.utils.BN(
web3.utils.toWei(n.toString(), 'ether')
)
}
// Same as ether
const tokens = n => ether(n)
const wait = s => {
const milliseconds = s * 1000
return new Promise(resolve => setTimeout(resolve, milliseconds))
}
module.exports = {
ETHER_ADDRESS,
EVM_REVERT,
tokens,
wait,
};
Truffle로 테스트를 수행한다. 자동으로 test/ 에 있는 코드를 실행하여 테스트한다.
truffle test
Step #4 Token migration
컴파일된 토큰 컨트랙트를 Ganache가 만들어준 로컬 네트워크에 deploy하자. 이를 migration이라고 한다.
migrations/ 폴더에 1로 시작하는 js파일이 있다. Truffle에서 기본 템플릿으로 제공하는 거라 사실 뭔지 자게하게 모르겠다. migrations/ 폴더의 파일은 숫자 prefixed 파일명을 사용해야 하는데, Truffle이 이 숫자 순서대로 실행시키기 때문이다. Token을 배포하기 위한 2_deploy.js 파일을 생성하고 아래 내용을 붙혀넣는다.
Summary
=======
> Total deployments: 2
> Final cost: 0.0348687 ETH
Decentralized Bank(DeBank) Contract
Step #5 DeBank Contract 작성
이제 예금, 출금, 대출, 상환 등의 기능을 가진 DeBank contract에 대해 코드를 작성할 것이다. 우선 컨트랙트의 기본 뼈대부터 입력한다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Token.sol";
contract DeBank {
Token private token;
// Token contract의 주소를 받아 내부 private 변수에 저장한다.
constructor(Token _token) {
token = _token;
}
function deposit() payable public {
}
function withdraw() public {
}
function borrow() payable public {
}
function payOff() public {
}
}
위 코드에서 payable 제어자(modifier)는 함수가 ETH를 받을 수 있게 한다. 즉, payable 제어자가 붙은 함수를 실행할 때 ETH를 동봉해서 실행할 수 있다.
Deposit함수를 작성하자. 아래 내용을 알맞은 위치에 붙혀넣는다.
contract DeBank {
Token private token;
// user{address}의 예금 시점{uint}을 저장하는 mapping
mapping(address => uint) public depositStart;
// user{address}의 잔액{uint}을 저장하는 mapping
mapping(address => uint) public etherBalanceOf;
// user{address}가 예금을 했는 지{bool}를 저장하는 mapping
mapping(address => bool) public isDeposited;
// Event는 frontend와 소통하기 위해 사용된다.
event Deposit(address indexed user, uint etherAmount, uint timeStart);
// Token contract의 주소를 받아 내부 private 변수에 저장한다.
constructor(Token _token) {
token = _token;
}
/**
* @dev Eth를 받아 예금한다.
*/
function deposit() payable public {
// 예금 중인 사람은 더 예금할 수 없다. (이자 관리 어려움 때문에)
require(isDeposited[msg.sender] == false, 'Error, deposit already active');
// 예치 최소 금액을 확인한다. msg.value는 함수가 받은 ETH량이 저장되어있다.
require(msg.value>=1e16, 'Error, deposit must be >= 0.01 ETH');
etherBalanceOf[msg.sender] = etherBalanceOf[msg.sender] + msg.value;
// deposit한 시점 저장
depositStart[msg.sender] = depositStart[msg.sender] + block.timestamp;
isDeposited[msg.sender] = true;
// Event 발생
emit Deposit(msg.sender, msg.value, block.timestamp);
}
Withdraw 함수도 아래와 같이 작성한다. (역시 주석 참고)
event Withdraw(address indexed user, uint etherAmount, uint depositTime, uint interest);
function withdraw() public {
// 예금하고 있는 지 확인
require(isDeposited[msg.sender]==true, 'Error, no previous deposit');
uint userBalance = etherBalanceOf[msg.sender]; //for event
// 예금 기간 확인 (초 단위)
uint depositTime = block.timestamp - depositStart[msg.sender];
// 이자 계산 (10% APY(Annual Percentage Yield) 기준, 자세한 계산 생략)
uint interestPerSecond = 31668017 * (etherBalanceOf[msg.sender] / 1e16);
uint interest = interestPerSecond * depositTime;
// ETH를 돌려준다.
payable(msg.sender).transfer(etherBalanceOf[msg.sender]); //eth back to user
// 유저에게 이자만큼의 토큰을 발행해준다.
token.mint(msg.sender, interest); //interest to user
// 초기화
depositStart[msg.sender] = 0;
etherBalanceOf[msg.sender] = 0;
isDeposited[msg.sender] = false;
// 이벤트 발생
emit Withdraw(msg.sender, userBalance, depositTime, interest);
}
block.timestamp와 now는 같다. (now 가 block.timestamp의 alias) now는 deprecated이므로 block.timestamp를 사용하자.
Step #6 DeBank Testing
Test 파일은 로직만 잘 작성하면 되므로 자세한 설명은 생략하고 한번 읽어 보길 바란다. 전체 코드는 아래와 같다.
const Token = artifacts.require('./Token')
const DecentralizedBank = artifacts.require('./DeBank')
const utils = require("./helpers/utils");
const time = require("./helpers/time");
require('chai')
.use(require('chai-as-promised'))
.should()
contract('DeBank', ([deployer, user]) => {
let token, deBank;
const interestPerSecond = 31668017 //(10% APY) for min. deposit (0.01 ETH)
// beforEach hook은 테스트 전에 매번 실행되는 함수이다.
beforeEach(async () => {
token = await Token.new()
// token.address를 넘겨주어 DecentralizedBank 생성
deBank = await DecentralizedBank.new(token.address)
// token의 발행권을 deBank로 넘겨준다.
await token.passMinterRole(deBank.address, {from: deployer})
})
// context는 testing group 같은 느낌
context('testing token contract...', () => {
context('success', () => {
// it은 테스트 최소 단위이다.
it('checking token name', async () => {
// 아래와 같이 글로 읽히는 assertion module이 chai
expect(await token.name()).to.be.eq('Decentralized Bank Token')
})
it('checking token symbol', async () => {
expect(await token.symbol()).to.be.eq('DEBT')
})
it('checking token initial total supply', async () => {
expect(Number(await token.totalSupply())).to.eq(0)
})
it('DeBank should have Token minter role', async () => {
// minter를 deBank로 넘겼으므로 minter가 deBank여야 한다.
expect(await token.minter()).to.eq(deBank.address)
})
})
context('failure', () => {
it('passing minter role should be rejected', async () => {
// 현재 minter{deBank}만이 passMinterRole을 실행할 수 있다.
await token.passMinterRole(user, {from: deployer}).should.be.rejectedWith(utils.EVM_REVERT)
})
it('tokens minting should be rejected', async () => {
// 현재 minter{deBank}만이 mint를 실행할 수 있다.
await token.mint(user, '1', {from: deployer}).should.be.rejectedWith(utils.EVM_REVERT) //unauthorized minter
})
})
})
context('testing deposit...', () => {
let balance
context('success', () => {
beforeEach(async () => {
await deBank.deposit({value: 10**16, from: user}) //0.01 ETH
})
it('balance should increase', async () => {
expect(Number(await deBank.etherBalanceOf(user))).to.eq(10**16)
})
it('deposit time should > 0', async () => {
expect(Number(await deBank.depositStart(user))).to.be.above(0)
})
it('deposit status should eq true', async () => {
expect(await deBank.isDeposited(user)).to.eq(true)
})
})
context('failure', () => {
it('depositing should be rejected', async () => {
await deBank.deposit({value: 10**15, from: user}).should.be.rejectedWith(utils.EVM_REVERT) //to small amount
})
})
})
context('testing withdraw...', () => {
let balance
context('success', () => {
beforeEach(async () => {
await deBank.deposit({value: 10**16, from: user}) //0.01 ETH
await utils.wait(2) //accruing interest
balance = await web3.eth.getBalance(user)
await deBank.withdraw({from: user})
})
it('balances should decrease', async () => {
expect(Number(await web3.eth.getBalance(deBank.address))).to.eq(0)
expect(Number(await deBank.etherBalanceOf(user))).to.eq(0)
})
it('user should receive ether back', async () => {
expect(Number(await web3.eth.getBalance(user))).to.be.above(Number(balance))
})
it('user should receive proper amount of interest', async () => {
//time synchronization problem make us check the 1-3s range for 2s deposit time
balance = Number(await token.balanceOf(user))
expect(balance).to.be.above(0)
expect(balance%interestPerSecond).to.eq(0)
expect(balance).to.be.below(interestPerSecond*4)
})
it('depositer data should be reseted', async () => {
expect(Number(await deBank.depositStart(user))).to.eq(0)
expect(Number(await deBank.etherBalanceOf(user))).to.eq(0)
expect(await deBank.isDeposited(user)).to.eq(false)
})
})
context('failure', () => {
it('withdrawing should be rejected', async () =>{
await deBank.deposit({value: 10**16, from: user}) //0.01 ETH
await utils.wait(2) //accruing interest
await deBank.withdraw({from: deployer}).should.be.rejectedWith(utils.EVM_REVERT) //wrong user
})
})
})
xcontext('testing borrow...', () => {
})
xcontext('testing payOff...', () => {
})
})
백엔드로 사용할 블록체인 구현이 완료되었으니 (DeBank의 borrow, payOff는 추후에 추가 구현) 이제 React를 이용해서 프론트엔드 구현한다. 프로젝트 루트 디렉토리에서 아래 커맨드를 입력하여 react 환경을 구성한다.
npx create-react-app client
client 디렉토리로 이동해서 실행해본다.
cd client && yarn start
Step #9 Install dependency & Metamask
필요한 모듈을 설치한다.
npm install react-bootstrap bootstrap web3
브라우저에서 web3 어플리케이션을 사용하기 위해 Metamask 확장 프로그램 설치가 필요하다.
확장 프로그램을 설치하면 브라우저 오른쪽 위에 해당아이콘이 생성된다.
Step #10 Metamask setting
Metamask 계정을 생성한 후 테스트 용도로 쓰기 위해서 Ganache network를 등록해주어야 한다. Etereum mainnet을 클릭하고 Custom RPC를 선택한다.
아래와 같이 입력해서 Ganache Test Network를 설정한다. (포트번호가 7545일수도 있다.)
다음으로 Ganache에 생성되어 있는 10개의 test 계정 중 1개를 import한다. 프로필 사진 같은 것을 누르고 Import Account를 누르고 Ganache에서 열쇠 모양 아이콘을 클릭한 후 private key를 복사해 붙혀넣는다.
Import한 Account를 개발하고 있는 웹 사이트에 conntect 시킨다. 옵션 아이콘을 누르고 connected sites를 누르고 확인을 계속 누르면 된다.
Step #11 React Skeleton code
React 작성에 대한 가이드는 아니니 React 코드에 대한 설명은 생략한다. 아래는 UI를 보여주는 React 템ㅍ플릿 코드이다. 이제 Metamask를 연결해 web3 어플리케이션으로 만들고, deposit과 withdraw함수를 구현해서 이더리움 네트워크(여기서는 Ganache local network)와 상호작용을 해볼것이다.
Step #12 Connect to blockchain (Become a web3 app)
아래 코드는 Metamask, Blockchain backend와 연동하는 부분이다. 컨트랙트와 계정에 대한 정보를 웹으로 가져올 수 있다.
const loadBlockchainData = async () => {
// Metamask가 window.ethereum에 API를 삽입하기 때문에
// undefined가 아니라면 Metamask가 있는 것.
if (typeof window.ethereum !== "undefined") {
const web3 = new Web3(window.ethereum);
// network마다 고유의 ID가 부여된다. 아래 함수로 Ganache의 ID인 5777이 반환된다.
const netId = await web3.eth.net.getId();
// 우리가 Import한 계정이 받아진다.
const accounts = await web3.eth.getAccounts();
// log는 개발자 도구에서 볼 수 있다.
console.log(netId, accounts)
// account가 연결되어 있다면
if (typeof accounts[0] !== "undefined") {
// balance를 구하고 변수를 저장한다.
const balance = await web3.eth.getBalance(accounts[0]);
setAccount(accounts[0]);
setBalance(balance);
setWeb3(web3);
} else {
window.alert("Please Login With MetaMask");
}
try {
// web3.eth.Contract로 contract 객체를 얻어 올 수 있다.
// 파라메터로 abi와 현재 네트워크 상에서의 주소를 넘겨준다.
const token = new web3.eth.Contract(
Token.abi,
Token.networks[netId].address
);
const deBank = new web3.eth.Contract(
DeBank.abi,
DeBank.networks[netId].address
);
const deBankAddress = DeBank.networks[netId].address;
setToken(token)
setDeBank(deBank)
setDeBankAddress(deBankAddress)
} catch (error) {
console.log("Error", error);
window.alert("Contract not deployed to the current network");
}
} else {
window.alert("Please Install MetaMask");
}
}
계좌가 연동되어 계좌 주소와 잔고가 출력된다.
Step #13 Interact with smart contract
Smart contract의 함수는 contract 객체의 methods.<function>.call() 혹은 methods.<function>.send() 를 호출는 것으로 실행한다.
call() 과 send()는 함수 실행이 state를 변화시키느냐에 따라 구분되어 사용된다. 예를 들어 deposit과 withdraw는 contract 내부에 저장되는 잔고(state)를 변화시키므로 send()함수를 사용해야한다.
Deposit, Withdraw 함수는 아래와 같다.
const deposit = async (amount) => {
if (deBank !== 'undefined') {
//in try block call deBank deposit();
try {
// deposit 함수는 payable 이므로 실어보낼 ETH의 양{value}과
// 누가 deposit 함수를 호출하는지를 나타내는 {from}이 필요하다.
// 아래 구문은 지금 연결되어 있는 account가 amount만큼의 ETH를 deposit한다는 뜻.
await deBank.methods.deposit().send({value: amount.toString(), from: account})
} catch (error) {
console.log('Error, deposit: ', error)
}
}
}
const withdraw = async (e) => {
e.preventDefault()
if (deBank !== 'undefined') {
try {
await deBank.methods.withdraw().send({from: account})
} catch (error) {
console.log('Error, withdraw: ', error)
}
}
}
이제 3 ETH를 예금해보자.
confirm을 누르면 계좌에서 3 ETH가 빠져나간 것을 확인할 수 있다.
시간이 조금 흐른 후에 Withdraw를 진행해보자.
Deposit했던 ETH가 다시 들어왔다. 그런데 이자로 받은 토큰은 어떻게 확인할까? 토큰을 Metamask에 추가해주어야 지갑에서 확인 가능하다. 토큰 컨트랙트 주소를 복사해서 Metamask의 Assets - Add Token에 붙혀넣는다. Token.json 파일에서 확인 가능하다.
아주 잠깐 예금했기때문에 매우 적지만 이자가 DEBT 토큰으로 계좌에 들어온 것을 확인할 수 있다.