이번 포스팅에서는 JavaScript를 사용해서 스마트 컨트랙트를 배포하고 배포된 스마트 컨트랙트를 실행시키는 방법에 대해 다루고자 한다.
< 목차 >
- keystore 파일에서 개인키 가져오기
- JavaScript로 스마트 컨트랙트 컴파일하기
- JavaScript로 스마트 컨트랙트 배포하기
1. keystore 파일에서 개인키 가져오기
Geth를 실행시킨 다음 해당 노드에서 계정을 생성하게 될 경우 다음과 같이 keystore 디렉토리 안에 UTC--로 시작하는 파일이 생성된다.
keystore 파일 안에는 계정 정보들이 객체 형태로 존재한다. 암호화 되어 있는 계정 정보 파일이라고 볼 수 있으며 단방향 암호화가 아니기 때문에 복호화를 통해 개인키를 얻어내는 것이 가능하다. keythereum 이라는 라이브러리를 사용해 다음과 같은 방법으로 keystore 파일을 복호화해서 개인키를 구할 수 있다.
$ npm install keythereum
const keythereum = require('keythereum');
const path = require('path');
// UTC-- 파일 안의 address 값 앞에 0x 붙여서 사용
const address = '0x65555766ecd47f2e7a596a7da929fa5d3f1dc28d';
const dir = path.join(__dirname); // keystore 상위 디렉토리 까지만 경로를 잡아주면 된다.
/*UTC-- 파일(keystore 파일)을 복호화해서 개인키 가져오기*/
// keystore 파일 안에 있는 계정 정보 객체
const keyObject = keythereum.importFromFile(address, dir);
// passphrase 와 keystore 파일 안의 객체를 인자값으로 전달해 복호화 가능
const privateKey = keythereum.recover('1234', keyObject).toString('hex');
console.log(privateKey); // 0x943367addb42bb5010f70cbd495bae6b457bd8f17b202702456364dcdb50a9ee
2. JavaScript로 스마트 컨트랙트 컴파일하기
Solidity로 작성한 스마트 컨트랙트를 컴파일 할 때 터미널에서 다음과 같은 명령어를 통해 컴파일을 진행하였다.
$ npx solc --bin --abi [컨트랙트 파일명].sol
이번에는 JavaScript 코드를 통해 컴파일을 진행해보고자 한다. 우선 컴파일하고자 하는 스마트 컨트랙트 코드는 다음과 같다.
// 스마트 컨트랙트 코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15; // 버전
// HelloWorld 라는 컨트랙트 생성
contract HelloWorld {
string public value; // 상태 변수
// 상태 변수를 public으로 할 경우 getter 함수를 자동으로 만들어준다.
constructor() {
value = 'Hello World!';
}
// 인스턴스에 있는 상태변수를 바꾸는 함수
function setValue(string memory _v) public {
value = _v;
}
}
한 가지 짚고 넘어가야할 부분이 있는데 바로 상태변수를 만들 때 작성한 public에 관한 내용이다. 스마트 컨트랙트에서는 상태변수를 public으로 만들 경우 getter 함수를 자동으로 만들어준다. getter 함수는 상태변수의 값을 가져오는 함수를 지칭한다. 다시말해, 다음과 같은 함수가 자동으로 생성되는 것이라고 보면 된다.
function getValue() public view returns(string memory) {
return value;
}
getter 함수와 다르게 setter 함수도 존재하는데 setter 함수는 상태변수의 값을 변경하는 함수를 지칭한다. 위에서 작성된 HelloWorld 컨트랙트에서는 setValue( ) 함수가 setter 함수가 되는 셈이다.
다음으로 JavaScript로 컴파일을 진행하기 위해 작성한 코드를 살펴보자.
// JavaScript로 컴파일 진행하기
const solc = require('solc');
const fs = require('fs-extra');
const path = require('path');
class Contract {
static compile(_filename) {
const contractPath = path.join(__dirname, '../contracts', _filename);
const data = JSON.stringify({
language: 'Solidity',
sources: {
[_filename]: {
content: fs.readFileSync(contractPath, 'utf8'),
},
},
settings: {
outputSelection: {
'*': {
'*': ['*'],
},
},
},
});
const compiled = JSON.parse(solc.compile(data));
return Contract.writeOutput(compiled); // [abi, bytecode]
}
static writeOutput(_compiled) {
for (const contractFileName in _compiled.contracts) {
const [contractName] = contractFileName.split('.');
const contract = _compiled.contracts[contractFileName][contractName];
const abi = contract.abi;
const bytecode = contract.evm.bytecode.object;
const obj = {
abi,
bytecode,
};
const buildPath = path.join(__dirname, '../build', `${contractName}.json`);
fs.outputJSONSync(buildPath, obj);
return [abi, bytecode];
}
}
}
module.exports = { Contract };
Contract 클래스 안에서 static 메소드로 compile( ) 메소드와 writeOutput( ) 메소드를 만들어주었다. compile( ) 메소드 안에서는 solc.compile( ) 함수를 통해 컴파일을 진행하게 되고 writeOutput( ) 메소드 안에서는 solc.compile( ) 함수로 얻게되는 객체 안에서 abi 파일의 내용과 컴파일된 바이트 코드를 조회해 json 파일로 내보내는 작업을 진행한다.
compile( ) 메소드 안에서 실행되는 solc.compile( ) 함수에 의해 실질적으로 컴파일이 진행된다고 볼 수 있는데 solc.compile( ) 은 다음과 같은 string 형태의 객체를 인자값으로 받는다.
const data = JSON.stringify({
language: 'Solidity',
sources: {
'HelloWorld.sol': {
content: fs.readFileSync(contractPath, 'utf8'),
},
},
settings: {
outputSelection: {
'*': {
'*': ['*'],
},
},
},
});
solc.compile( ) 함수의 인자값으로 들어가는 객체는 다음과 같은 속성값을 갖는다.
- language : 어떤 언어로 작성된 것인지 명시. (Solidity 이외의 언어로도 스마트 컨트랙트 작성 가능)
- sources : 소스파일에 대한 정보. (어떠한 파일을 컴파일 할 것인지)
- settings : 설정값.
그리고 sources 안에 작성된 content의 속성값으로 fs.readFileSync(contractPath, 'utf8') 이 들어가 있는 것을 볼 수 있는데 이는 스마트 컨트랙트가 작성된 파일의 경로를 아래와 같이 contractPath 변수에 담아서 fs (파일시스템)의 readFileSync( ) 메소드의 인자값으로 전달해 해당 파일 안에 작성된 내용 전체를 content의 속성값으로 넣어준 것이다.
const contractPath = path.join(__dirname, '../contracts', _filename);
다시 Contract 클래스의 compile( ) 메소드로 돌아와서 solc.compile(data) 를 JSON.parse( )를 통해 json 객체로 만들고 compiled 변수에 할당하여 writeOutput( ) 메소드의 인자값으로 전달하였다. 실제 compiled 변수에 할당된 객체는 다음과 같은 형태를 띠고 있다.
그리고 compiled.contracts 객체 안에서 다음과 같은 내용들을 조회할 수 있다.
실제 우리가 원하는 abi 파일의 내용은 HelloWorld 속성값으로 들어간 객체 안에 존재하며 바이트 코드 값은 evm 속성값 안에 존재하고 있다. 따라서 Contract 클래스의 writeOutput( ) 메소드 안에서 abi 파일의 내용과 바이트 코드 값을 json 파일로 내보내주는 과정을 다음과 같이 만들어주었다.
static writeOutput(_compiled) {
for (const contractFileName in _compiled.contracts) {
const [contractName] = contractFileName.split('.');
const contract = _compiled.contracts[contractFileName][contractName];
const abi = contract.abi;
const bytecode = contract.evm.bytecode.object;
const obj = {
abi,
bytecode,
};
// json 파일로 내보내기 / fs-extra 내장 모듈 사용
// 첫번째 인자값: 경로, 두번째 인자값: 객체 내용
const buildPath = path.join(__dirname, '../build', `${contractName}.json`);
fs.outputJSONSync(buildPath, obj);
return [abi, bytecode];
}
}
3. JavaScript로 스마트 컨트랙트 배포하기
전체적인 디렉토리 구조는 아래와 같다.
controllers/ 디렉토리 안에 있는 compile.js 파일 안에는 JavaScript 코드로 컴파일을 진행하기 위해 만든 Contract 클래스 코드가 작성되어 있다. client.js 파일 안에는 아래와 같이 web3 인스턴스를 속성으로 갖는 인스턴스를 생성해주기 위한 Client 클래스가 작성되어 있다.
// client.js 파일
const Web3 = require('web3');
let instance;
class Client {
constructor(_url) {
if (instance) return instance;
this.web3 = new Web3(_url);
instance = this;
}
}
module.exports = { Client };
Client 클래스는 싱글톤(Singleton) 패턴으로 만들어진 클래스이며 해당 클래스로 인스턴스를 생성할 경우 오직 1개의 객체만 생성된다. 다시말해, 생성자가 여러 차례 호출되더라도 실제로 생성되는 객체는 오직 하나이고 최초 생성 이후에 호출된 생성자는 최초의 생성자가 생성한 객체를 return 한다는 의미이다. 인스턴스가 생성될 때 최초 생성된 객체를 참조하는 형태로 나머지 인스턴스들이 생성되는 것이 싱글톤 패턴의 핵심이다. 이러한 패턴은 주로 프로그램 내에서 하나로 공유해야 하는 객체가 존재할 때 해당 객체를 싱글톤으로 구현하여 모든 유저 또는 프로그램들이 해당 객체를 공유하며 사용하도록 할 때 사용된다.
이제 루트 디렉토리에 위치한 index.js 파일 안에서 스마트 컨트랙트를 배포하고 실행시키는 코드를 작성해보도록 하자.
// index.js 파일
const { Contract } = require('./controllers/compile');
const { Client } = require('./controllers/client');
const [abi, bytecode] = Contract.compile('HelloWorld.sol');
const client = new Client('ws://127.0.0.1:9005');
const txObject = {
data: bytecode,
};
const contract = new client.web3.eth.Contract(abi);
// web3 deploy
async function init() {
// contract.deploy()의 반환값은 promise 객체
// 트랜잭션 풀에 있는 내용이 블록에 쌓일 때까지 await
const instance = await contract.deploy(txObject).send({ from: '0x65555766ecd47f2e7a596a7da929fa5d3f1dc28d' });
console.log(instance.options.address); // Contract Address
}
init()
const CA = '0x7719722c312BaF7D6D27BE0aE7E8b09f7FD3D30F';
const deployed = new client.web3.eth.Contract(abi, CA); // abi 파일과 CA를 이용해 컨트랙트 조회 가능
deployed.methods
.value()
.call()
.then((data) => {
console.log(data);
});
deployed.methods
.setValue('Hello Smart Contract!')
.send({ from: '0x65555766ecd47f2e7a596a7da929fa5d3f1dc28d' })
.then((data) => {
console.log(data);
});
전체적인 코드의 흐름은 다음과 같다.
- Contract.compile('HelloWorld.sol') 메소드에 의해 abi 파일의 내용과 bytecode 값을 얻는다.
- const client = new Client('ws://127.0.0.1:9005') 에 의해 web3 인스턴스를 속성값으로 갖고 있는 client 인스턴스를 생성한다.
- txObject 객체 안에 data 속성값으로 바이트 코드(bytecode)를 넣어준다.
- const contract = new client.web3.eth.Contract(abi) 에 의해 abi 파일의 내용을 포함하고 있는 contract 인스턴스를 생성해준다.
이후 init( ) 함수를 만들어서 contract.deploy( ) 메소드를 사용해 배포를 진행해주었다.
// web3 deploy
async function init() {
// contract.deploy() 의 반환값은 promise 객체
// 트랜잭션 풀에 있는 내용이 블록에 쌓일 때까지 await
const instance = await contract.deploy(txObject).send({ from: '0x65555766ecd47f2e7a596a7da929fa5d3f1dc28d' });
console.log(instance.options.address); // Contract Address
}
contract.deploy( ) 메소드의 인자값으로는 bytecode가 담겨있는 txObject를 전달하고 .send( ) 메소드의 인자값으로는 스마트 컨트랙트를 배포할 EOA 를 넣어준다. 이 때 contract.deploy( ) 의 반환값은 promise 객체이기 때문에 init( ) 함수를 async function 으로 선언해주었다. contract.deploy( ).send( ) 메소드가 실행되면 바이트 코드를 data로 갖는 트랜잭션 객체가 트랜잭션 풀에 담기게 되고 블록이 마이닝 될 때까지 await 구문에 의해 대기 상태가 된다. 이후 블록이 마이닝 되어 스마트 컨트랙트가 배포 완료되면 instance.options.address 값을 통해 CA(계약 계정)를 조회할 수 있다.
배포된 스마트 컨트랙트를 실행시켜 보기 위해 CA 변수를 선언해서 조회한 CA 값을 CA 변수에 할당해주었다. 그리고 const deployed = new client.web3.eth.Contract(abi, CA) 에 의해 abi 파일의 내용과 CA 값을 포함하고 있는 deployed 인스턴스를 생성해주었다.
이제 deployed.methods를 통해 getter 함수인 value( ) 메소드를 실행시킬 수 있으며 setValue( ) 메소드 역시 실행시킬 수 있다. setValue( ) 메소드의 경우 상태변수의 값을 바꾸는 메소드이기 때문에 .send( ) 메소드를 이용해 트랜잭션을 발생시켜줘야 함에 주의하도록 하자. 마이닝이 완료된 이후 deployed.methods.value( ).call( ) 을 이용해 상태변수 값을 조회해보면 setValue( ) 메소드의 인자값으로 전달한 값으로 상태변수가 변경된 것을 확인할 수 있다.
'Ethereum' 카테고리의 다른 글
Ethereum/이더리움 - 메타마스크를 통한 스마트 컨트랙트 실행 (0) | 2022.07.13 |
---|---|
Ethereum/이더리움 - Truffle (스마트 컨트랙트 개발 프레임워크) (0) | 2022.07.13 |
Ethereum/이더리움 - 스마트 컨트랙트 배포 및 실행 (3) | 2022.07.11 |
Ethereum/이더리움 - private 네트워크 RPC 설정하기 (1) | 2022.07.01 |
Ethereum/이더리움 - private 네트워크 (1) | 2022.06.30 |