이더리움을 트러플에서 배포한게 엊그제 같은데 이번에는 하드햇에서 클레이튼을 배포하고 왔습니다. 이 글에서는 클레이튼 메인넷이 아닌 하드햇 내장 네트워크와 baobab 테스트넷에서의 배포를 다루겠습니다. 메인넷 배포도 크게 다르지 않으니 이 글을 참고하셔서 좋은 프로젝트를 진행하셨으면 좋겠습니다.
이번에 진행하게 된 프로젝트가 원래 이더리움 기반의 DApp을 구상했었는데 아무래도 높은 가스비, 트랜잭션 처리 속도 때문에 고민을하다가 크루분의 의견을 받아 이를 해결할 수 있는 클레이튼으로 변경하게 되었습니다. 단독적으로 결정한 것은 아니였고 저희 개발팀은 DAO기 때문에 거버넌스 투표를 통해 메인넷을 변경하였습니다. 이더리움의 단점을 해결해 줄 것만 같은 클레이튼을 아직 심도있게 다뤄보지는 않았지만 앞선 문제를 해결해줄 것만 같아 기대 중입니다. 일단 공식 문서가 한글인게 마음에 듭니다.
그리고 잘쓰던 트러플에서 왜 하드햇으로 변경했는지 궁금해하실 수도 있는데 조금 슬픈 얘기가 있습니다. 네.. 그것은 컴퓨터 메모리가 부족해 가나슈까지 키면 렉이 걸립니다. 1969년에 달을 향한 아폴로호는 1.024MHz 클럭의 메모리로 달까지 갔는데 저의 1866MHz 클럭의 메모리를 가진 2015년도 초기형 맥북프로는 가나슈까지 키게되면 화면이 프리징됩니다. 그리고 하드햇은 가나슈를 실행하지 않아도 내부 가상 이더리움 네트워크를 제공하기 때문에 테스트 환경이 편한 하드햇으로 옮기게 되었습니다. 그래서 결국 이 글이 탄생하게 되었습니다.
클레이튼을 잠깐 다뤄보며 놀랐던 점은 이더리움과 매우 닮아있다는 것이였습니다. 이더리움을 가져다가 이름만 바꿨다고 오해할 수도 있을 정도로 기존 이더리움의 솔리디티를 접한 블록체인 개발자라면 러닝커브가 낮다고 할 수 있습니다.
따라서 클레이튼의 배포는 이더리움의 배포와 크게 다르지 않습니다. 또한 이더리움에서 사용했던 Hardhat, Truffle, ethersJs 및 web3Js같은 도구들을 그대로 사용할 수 있다는 장점이 있습니다.
하지만 클레이튼은 이더리움과 달리 고정 가스비 모델을 채택하고 있습니다. 따라서 개발자가 트랜잭션을 생성할 때 이더리움과 달리 가스를 설정해줘야합니다.
하드햇은 기본적으로 트러플과 같이 이더리움의 스마트 계약을 컴파일, 배포, 테스트 및 디버그하기 위한 개발 환경입니다. 하드햇에는 개발용으로 설계된 내장 하드햇 네트워크(트러플의 경우 가나슈)가 있습니다.
하드햇 네트워크가 동작하는 방법
JSON-RPC 및 WebSocket 요청을 처리하는 in-process 또는 독립 실행형 데몬으로 실행됩니다.
기본적으로 수신하는 각 트랜잭션과 함께 블록을 순서대로 지연 없이 채굴합니다.
@ethereumjs/vm
ganache, Remix 및 Ethereum Studio에서 사용하는 것과 동일한 EVM 구현으로 뒷받침됩니다 .
https://hardhat.org/hardhat-network/docs/overview#how-does-it-work?
또한 솔리티디 파일 안에서 console.log()도 출력할 수 있고 트랜잭션 실패 시 명시적 오류 메시지를 출력해주어 디버깅하기 편하다는 장점이 있습니다. 추가적으로 여기에서 트러플과 하드햇을 비교한 글을 읽어보실 수 있습니다.
그럼 먼저 하드햇을 설치하면서 시작을 해보겠습니다. 하드햇은 node.Js 12 이상 버전을 필요로합니다. 그리고 이 글에서는 npm대신 yarn을 사용합니다.
yarn은 여러 패키지를 동시에 다운받기 때문에 패키지를 하나하나 다운받는 npm보다 속도가 빠릅니다. 또한 패키지에 종속된 다른 패키지를 같이 다운받는 npm과 다르게 package.json 혹은 yarn.lock에 있는 패키지만 다운 받기 때문에 보안성에서도 yarn이 우수합니다.
터미널에서 아래 명령어로 하드햇을 설치해줍니다.
mkdir klaytn_Hardhat_Example
cd klaytn_Hardhat_Example
yarn init --y
yarn add --dev hardhat
yarn을 성공적으로 초기화하고 하드햇 설치가 완료되었으면 아래 명령어로 하드햇을 실행시켜줍니다.
yarn hardhat
명령어를 실행하면 위와 같은 화면이 출력될겁니다. 저는 타입스크립트를 사용해서 프로젝트 구성을 할 예정이기 때문에 타입스크립트 프로젝트로 시작하겠습니다.
트러플은 사용해본 개발자라면 폴더 구조가 익숙할겁니다. contracts
폴더에는 스마트 컨트랙트 코드를, scripts
폴더는 배포 스크립트를 test
폴더는 테스트 코드가 위치하게 됩니다. 추가적으로 hardhat.config.ts
파일은 하드햇의 설정 파일입니다.
설치가 완료되었으면 다음의 패키지들을 설치하기 위하여 package.json
에 아래의 내용을 복사하여 줍니다.
{
"name": "Klaytn_Hardhat_Example",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"devDependencies": {
"@nomiclabs/hardhat-ethers": "^2.0.3",
"@nomiclabs/hardhat-etherscan": "^2.1.8",
"@nomiclabs/hardhat-waffle": "^2.0.1",
"@typechain/ethers-v5": "^7.2.0",
"@typechain/hardhat": "^2.3.1",
"@types/chai": "^4.3.0",
"@types/mocha": "^9.0.0",
"@types/node": "^16.11.17",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"chai": "^4.3.4",
"dotenv": "^10.0.0",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-config-standard": "^16.0.3",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^3.4.1",
"eslint-plugin-promise": "^5.2.0",
"ethereum-waffle": "^3.4.0",
"ethers": "^5.5.2",
"hardhat": "^2.11.0",
"hardhat-gas-reporter": "^1.0.6",
"prettier": "^2.5.1",
"prettier-plugin-solidity": "^1.0.0-beta.13",
"solhint": "^3.3.6",
"solidity-coverage": "^0.7.17",
"ts-node": "^10.4.0",
"typechain": "^5.2.0",
"typescript": "^4.5.4"
},
"dependencies": {
"@openzeppelin/contracts": "^4.4.1",
"@chainlink/contracts": "0.2.1",
"mocha": "^9.2.1"
}
}
붙여넣은 후 패키지 설치를 위해 아래의 명령어를 입력해줍니다.
yarn
설치가 완료되었으면 환경변수 설정을 해줍니다.
프로젝트 폴더 루트 경로에 .env
파일을 생성해줍니다. 그 다음 아래와 같이 환경변수를 구성해줍니다.
KLAYTN_URL='https://api.baobab.klaytn.net:8651'
PRIVATE_KEY=카이카스 지갑의 개인키를 넣어주세요.
다음은 hardhat.config.ts 파일 구성입니다.
import * as dotenv from "dotenv";
import { HardhatUserConfig, task } from "hardhat/config";
import "@nomiclabs/hardhat-etherscan";
import "@nomiclabs/hardhat-waffle";
import "@typechain/hardhat";
import "hardhat-gas-reporter";
import "solidity-coverage";
dotenv.config();
// 더 자세한 설정법은 https://hardhat.org/config/ 에서 확인해주세요.
const config: HardhatUserConfig = {
solidity: "0.8.9",
networks: {
klaytn: {
url: process.env.KLAYTN_URL || "",
accounts:
process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [],
},
},
gasReporter: {
enabled: process.env.REPORT_GAS !== undefined,
currency: "USD",
},
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY,
},
};
export default config;
객체에 gasPrice 속성이 추가되었음을 알 수 있습니다. 하드햇을 사용하여 클레이튼 네트워크에 트랜잭션을 배포하거나 전송할 때 클레이튼의 가스가 고정적으로 사용되기 때문에 이더리움과 달리 추가해줘야합니다. 자세한 내용은 여기에서 읽으실 수 있습니다.
또한 하드햇에는 두 가지 종류의 네트워크가 있습니다. 이 글에서 사용할 JSON-RPC 기반 네트워크와 내장된 하드햇 네트워크입니다. 참고로 networks
구성을 생략하면 hardhat
이 기본값으로 사용됩니다.
이제 설정이 완료되었으니 스마트 컨트랙트를 작성하고 배포해보겠습니다.
이 글에서는 은행처럼 작동하는 간단한 스마트컨트랙트를 작성하겠습니다. 사용자는 돈을 입금하고 출금할 수 있습니다.
contracts
폴더로 이동하여 KlayBank.sol
이라는 이름의 솔리디티 파일을 생성해줍니다.
아래 코드를 복사해서 새로 생성한 파일에 붙여넣어 줍니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract KlayBank {
mapping(address => uint) public balances;
event depositEvent(uint _amount, address _depositor);
event withdrawEvent(uint _amount, address _depositor, address _beneficiary);
function deposit() public payable {
balances[msg.sender] += msg.value;
emit depositEvent(msg.value, msg.sender);
}
function withdraw(address _recipient, uint _amount) public {
require(_recipient != address(0) ,"KlayBank: Cannot Send to Address Zero");
require(_amount <= balances[msg.sender], "KlayBank: Insufficient Balance");
balances[msg.sender] -= _amount;
balances[_recipient] += _amount;
emit withdrawEvent(_amount, msg.sender, _recipient);
}
function getBalance(address _addr) public view returns(uint) {
return balances[_addr];
}
}
velog는 아직 솔리디티 파일의 코드 하이라이팅을 지원하지않습니다.. 그래서 보기 불편하시더라도 양해부탁드립니다. 벨로그 개발자이신 벨로퍼트님의 벨로그 프로젝트 레포지토리에 해당 이슈를 생성해놨으니 언젠가 지원되는 날을 다같이 기다려봅시다.
입금 기능(deposit function)
입금 기능은 payble
으로 선언되어 코인을 받을 수 있습니다. balances[msg.sender] += msg.value;
함수를 호출한 사람(msg.sender)의 보낸 돈을 balances
에 할당합니다. 마지막으로 상태 변경 후 예금 이벤트를 내보냅니다.
인출 기능(withdraw function)
여기에는 돈을 받을 주소와 양도할 금액의 두 가지 매개변수가 필요합니다. 여기에는 아래 내용을 확인하는 두 가지require
문이 있습니다.
이 함수는 돈을 보내는 사람의 잔액을 줄이고 돈을 받는 사람의 잔액을 늘리는 것입니다. 마지막으로 이러한 상태 변경 후에 이벤트가 발생합니다.
getBalance 함수
msg.sender의 잔액을 반환하는 함수입니다.
스마트 컨트랙트 작성이 완료되었으니 컴파일을 해보겠습니다. 아래 명령어로 컴파일을 해주세요.
yarn hardhat compile
위와 같이 출력이 되면 컴파일이 성공한 것입니다.
먼저 baobab 테스트넷에 배포하기 앞서 로컬 환경에서 배포를 하겠습니다. 그 이유는 로컬 환경에서 배포하고 테스트 코드를 돌려보기 위해서 입니다. 테스트 코드가 실행될 땐 배포가 여러번 이루어질 수 있기 때문에 로컬 환경에서 코인 걱정 없이 여러번 테스트를 해볼 수 있습니다.
scripts
폴더로 이동하여 deploy.ts
의 내용을 아래 코드와 같이 수정해주세요.
import { ethers } from 'hardhat';
async function main() {
const Bank = await ethers.getContractFactory('KlayBank');
const bank = await Bank.deploy();
await bank.deployed();
console.log('KlayBank가 배포된 컨트랙트의 주소 : ', bank.address);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
배포의 앞서 하드햇 내장 네트워크를 사용하기 위해 새 터미널을 열어주세요.
yarn hardhat node
위 사진과 같이 실행되면 하드햇의 내장 네트워크를 사용할 준비가 되었습니다.
이 터미널은 유지한 채 다른 터미널에서 계속해서 배포를 진행하겠습니다.
yarn hardhat run scripts/deploy.ts --network localhost
드디어 우리가 원하던 CA주소가 등장하였습니다. 위 사진과 비슷하게 출력이 되면 성공적으로 하드햇 내장 네트워크에 배포가 성공한 것 입니다.
아까 열어둔 하드햇 내장 네트워크의 터미널을 확인해보면 KlayBank가 배포된 것을 확인 할 수 있습니다.
그럼 이제 배포된 컨트랙트가 정상적으로 동작하는 지 테스트 코드를 작성해 봅시다.
test
폴더에 index.ts
파일을 생성하고 아래의 테스트 코드를 붙혀넣어주세요.
import { expect } from 'chai';
import { ethers } from 'hardhat';
import { KlayBank } from '../typechain';
let klayBank: KlayBank;
const deployedContract: string = '0x5FbDB2315678afecb367f032d93F642f64180aa3';
describe('KlayBank의 인스턴스가 생성되어야 합니다.', function () {
before(async function () {
klayBank = (await ethers.getContractAt(
'KlayBank',
deployedContract
)) as KlayBank;
});
it('예금 시 사용자 잔액이 증가해야 합니다.', async function () {
const signer = await ethers.getSigners();
const _depositor1 = signer[0].address;
const _depositorSigner = await ethers.getSigner(_depositor1);
const balance: any = await klayBank.getBalance(_depositor1);
const option = { value: ethers.utils.parseEther('1') };
const deposit: any = await klayBank
.connect(_depositorSigner)
.deposit(option);
const tx = await deposit.wait();
const value = tx.events[0].args[0];
const depositor = tx.events[0].args[1];
const balanceAfter: any = await klayBank.getBalance(_depositor1);
expect(Number(balance.toString()) + Number(value.toString())).to.equal(
Number(balanceAfter.toString())
);
expect(_depositor1).to.equal(depositor);
});
it('입금 및 출금시 각각의 계정의 잔액은 감소 및 증가해야 합니다.', async function () {
const signer = await ethers.getSigners();
const _depositor1 = signer[0].address;
const _depositorSigner = await ethers.getSigner(_depositor1);
const balance: any = await klayBank.getBalance(_depositor1);
const recipientBalB4: any = await klayBank.getBalance(signer[1].address);
const withdraw: any = await klayBank
.connect(_depositorSigner)
.withdraw(signer[1].address, '5000000000');
const tx = await withdraw.wait();
const value = tx.events[0].args[0];
const depositor = tx.events[0].args[1];
const recipient = tx.events[0].args[2];
const balanceAfter: any = await klayBank.getBalance(_depositor1);
const recBalanceAfter: any = await klayBank.getBalance(signer[1].address);
expect(Number(balance.toString()) - Number(value.toString())).to.equal(
Number(balanceAfter.toString())
);
expect(
Number(recipientBalB4.toString()) + Number(value.toString())
).to.equal(Number(recBalanceAfter.toString()));
expect(_depositor1).to.equal(depositor);
expect(signer[1].address).to.equal(recipient);
});
it('계정 주소가 0인 계정으로 입금할 때 트랜잭션이 되돌려져야합니다.', async function () {
const signer = await ethers.getSigners();
const _depositor1 = signer[0].address;
const _depositorSigner = await ethers.getSigner(_depositor1);
await expect(
klayBank
.connect(_depositorSigner)
.withdraw(ethers.constants.AddressZero, '5000000000')
).to.be.revertedWith('KlayBank: Cannot Send to Address Zero');
});
it('이체 금액이 잔액보다 클 때 트랜잭션이 되돌려져야합니다.', async function () {
const signer = await ethers.getSigners();
const _depositor1 = signer[0].address;
const _depositorSigner = await ethers.getSigner(_depositor1);
const balanceB4: any = await klayBank.getBalance(_depositor1);
const balance: any = await klayBank.getBalance(_depositor1);
await expect(
klayBank
.connect(_depositorSigner)
.withdraw(signer[1].address, balance.toString() + '1000000')
).to.be.revertedWith('KlayBank: Insufficient Balance');
expect(await klayBank.getBalance(_depositor1)).to.equal(balanceB4);
});
});
파일을 저장 한 뒤 아래의 명령어로 테스트 코드를 실행해 주세요.
yarn hardhat test test/index.ts --network localhost
위 사진과 비슷하게 출력됐다면 축하드립니다. 성공적으로 테스트를 통과하였습니다🎉
로컬에서 배포를 성공하고 테스트까지 끝마쳤으니 이제 테스트넷에 배포해 볼 차례입니다.
하드햇 내장 네트워크에 배포한 것과 크게 다르지 않습니다. 단지 로컬에 배포했던 것을 baobab 테스트넷으로 옮겨 배포하는 것 뿐입니다.
아래의 명령어로 배포해줍니다. 현재 환경변수에 설정되어 있는 네트워크가 baobab 테스트넷이기 때문에 컨트랙트는 baobab 테스트넷에 배포가 됩니다.
yarn hardhat run scripts/deploy.ts --network klaytn
배포가 성공적으로 완료되었습니다. 배포 후 클레이튼의 이더스캔이라고 할 수있는 Klaytnscope 에서 자세한 정보를 확인 할 수 있습니다.
성공적으로 baobab 테스트넷에 배포가 된 것을 확인 할 수 있습니다.
클레이튼 고정 가스 수수료 모델을 고려하여 하드햇을 사용해 스마트 컨트랙트를 성공적으로 작성, 컴파일, 테스트 및 배포해봤습니다.
그럼 이제 마지막으로 클레이튼의 고정 가스 수수료 모델을 염두에 두고 baobab 테스트넷에 배포된 컨트랙트를 ethers.js를 활용해 트랜잭션을 실행시켜보겠습니다.
scripts
폴더로 이동 후 ethersTx.ts
라는 파일을 생성해 주세요.
파일 생성 후 아래의 코드를 붙혀넣어주세요.
import { ethers } from "hardhat";
import * as dotenv from "dotenv";
import { BytesLike } from "ethers/lib/utils";
dotenv.config();
async function main() {
const account = "0x8F06fe2c39BD3655027f4331C7DC6d6660b1FA68";
const url = process.env.KLAYTN_URL;
// @ts-ignore
const priv : BytesLike = process.env.PRIVATE_KEY;
const provider = new ethers.providers.JsonRpcProvider(url)
const wallet = new ethers.Wallet(priv, provider)
const tx = await wallet.sendTransaction({
to: account,
value: "1000000000000000000",
gasPrice: 250000000000,
gasLimit: 21000,
})
const receipt = await tx.wait()
console.log(receipt);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
const wallet = new ethers.Wallet(priv, provider)
는 개인 키와 공급자가 주입된 지갑의 인스턴스를 생성합니다.
const tx = await wallet.sendTransaction({
to: account,
value: "1000000000000000000",
gasPrice: 250000000000,
gasLimit: 21000,
})
트랜잭션을 발생시킵니다. gasPrice를 클레이튼의 고정 가스 수수료 비용인 250000000000 으로 설정하였습니다.
그럼 다음의 명령어로 이 코드를 실행해보겠습니다.
yarn hardhat run scripts/ethersTx.ts --network klaytn
성공적으로 트랜잭션이 실행되었습니다!
여기서 한가지 궁금한 점이 생길 수가 있는데 바로 value
를 string
형으로 작성한 부분입니다. 사실 저도 처음에는 int
형으로 작성 후 트랜잭션을 발생시켰는데 다음과 같은 에러가 발생했습니다.
바로 BigNumber overflow 오류입니다. 왜 이러한 오류가 발생했냐면 자바스크립트는 IEEE 754에 기술된 배정밀도 부동소숫점 형식 숫자체계를 사용하기 때문에, -(2^53 - 1)과 2^53 - 1 사이의 수만 안전하게 표현할 수 있습니다.
쉽게 말해서 자바스크립트의 숫자체계는 53bit까지 밖에 지원을 안하기 때문에 저렇게 큰 숫자를 입력하면 오류를 내뱉는 것이였습니다.
그래서 클레이튼의 공식 문서를 참조하여 string
형으로 변환 후 다시 실행해주었습니다.
또 한가지 궁금증이 생길 수 있습니다. 클레이튼은 고정 가수 수수료 모델을 채택하고 있는데 과연 가스 값을 변경하고 트랜잭션을 실행하면 어떤 결과가 나올까요?
바로 아래와 같은 에러가 출력되면서 트랜잭션이 실패하게 됩니다.
이로써 하드햇을 사용하여 클레이튼의 스마트 컨트랙트를 작성 및 컴파일하고 하드햇 내장 네트워크 및 baobab 테스트넷에 배포, 테스트를 완료하고, 트랙잭션이 성공적으로 처리가 것까지 확인하였습니다.
이 글에 쓰인 모든 코드는 Github 레파지토리에서 확인하실 수 있습니다.
감사합니다.
https://ko.docs.klaytn.foundation
https://forum.klaytn.foundation
https://ko.docs.klaytn.foundation/dapp/sdk/caver-js/v1.4.1/api-references/caver.klay/transaction
https://oxpampam.hashnode.dev/how-to-set-up-a-hardhat-project-for-klaytn