스마트 컨트랙트 트랜잭션 - seumateu keonteulaegteu teulaenjaegsyeon

이번 포스팅에서는 스마트 컨트랙트 안에 이벤트를 등록하는 방법에 대해 다루고자 한다. 이전 포스팅에서 작업한 카운터(Counter) 컨트랙트에 이벤트를 등록하는 방향으로 작업을 진행해 보겠다. 그리고 프론트에서 트랜잭션을 생성하는 것이 아닌 백엔드에서 트랜잭션 객체를 생성하고 프론트에 전달하는 방향으로 코드를 리팩토링하고자 한다.

 

이전 글)

2022.07.13 - [Ethereum] - Ethereum/이더리움 - 메타마스크를 통한 스마트 컨트랙트 실행

 

Ethereum/이더리움 - 메타마스크를 통한 스마트 컨트랙트 실행

이번 포스팅에서는 메타마스크를 사용해서 스마트 컨트랙트를 실행시켜보는 작업을 진행해보고자 한다. 위에 보이는 것과 같이 스마트 컨트랙트에 의해 동작하는 Counter(카운터)를 만들어 볼 예

bitkunst.tistory.com

 

 

< 목차 >

  1. 스마트 컨트랙트 이벤트 등록
  2. 백엔드에서 트랜잭션 생성하기

 

 

1. 스마트 컨트랙트 이벤트 등록 

현재 truffle 디렉토리 안의 contracts 폴더에 있는 Counter.sol 파일에 작성되어 있는 Counter 컨트랙트는 다음과 같다.

/* truffle/contracts/ 디렉토리 Counter.sol 파일 */

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

contract Counter {
    
    uint256 private _count;

    // 상태변수가 private 이기 때문에 getter 함수를 직접 만들어줘야 한다.
    function current() public view returns(uint256) {
        return _count;
    }

    // 상태변수 변경 함수
    function increment() public {
        _count += 1;
    }

    function decrement() public {
        _count -= 1;
    }
}

현재 코드의 문제점은 해당 컨트랙트를 실행시킨 클라이언트 쪽만 상태변수의 업데이트를 인지한다는 점이다. 같은 블록체인 네트워크 상의 노드들은 서로 연결되어 있기 때문에 특정 클라이언트에서 컨트랙트를 실행시켜 상태변수를 업데이트 시켰다면 연결된 다른 노드들도 해당 업데이트를 인지해야만 한다.

 

스마트 컨트랙트 코드 안에 event(이벤트)를 등록하게 되면 이벤트 함수가 호출되었을 때 호출 기록이 TransactionReceipt 이라 불리는 트랜잭션 결과에 저장된다. 일종의 로그(log) 형태로 저장되며 로그 기록들을 조회함으로써 어떠한 내용의 스마트 컨트랙트가 실행되었는지 찾아보는 것이 가능하다.

스마트 컨트랙트 트랜잭션 - seumateu keonteulaegteu teulaenjaegsyeon

그리고 이벤트가 발생되었을 때 이 로그 데이터를 프론트엔드에 전송(send)하고 프론트엔드에서는 이벤트를 listen하게 함으로써 이더리움 네트워크 내 스마트 컨트랙트와 실시간으로 통신하는 것이 가능하다. 결국 프론트엔드가 스마트 컨트랙트에서 함수가 실행되는 것과 소통하기 위해 이용되는 것이 이벤트이다.

 

전송 부분은 스마트 컨트랙트 안에서 emit 키워드를 통해 구현되며 프론트엔드 쪽에서는 eth.subscribe( ) 메소드를 통해 'logs' 이벤트를 구독하는 방식으로 구현된다. 위에 작성된 스마트 컨트랙트 코드 안에서 다음과 같이 이벤트를 등록해주었으며 increment( ) 함수와 decrement( ) 함수가 호출될 때 emit Count(_count) 에 의해 상태변수의 값을 로그 기록으로 남기면서 로그 데이터를 전송해주게끔 하였다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

contract Counter {
    
    uint256 private _count;
    
    // 이벤트를 등록하겠다. (로그를 찍겠다.)
    event Count(uint256 count);
    // 로그의 데이터는 uint256
    // 이벤트를 등록했다면 어느 시점에 로그를 찍을 것인지 작성하면 된다.

    // 상태변수가 private 이기 때문에 getter 함수를 직접 만들어줘야 한다.
    function current() public view returns(uint256) {
        return _count;
    }

    // 상태변수 변경 함수
    function increment() public {
        _count += 1;
        emit Count(_count); // 현재 상태변수 값이 로그에 찍히게 된다.
    }

    function decrement() public {
        _count -= 1;
        emit Count(_count);
    }
}

 

이제 프론트 쪽에서는 다음과 같이 eth.subscribe( ) 메소드를 통해 'logs' 이벤트를 listen하도록 해주면 된다.

/* src/components/ 디렉토리 Counter.jsx 파일 */

useEffect(() => {
    (async () => {
        if (deployed) return;

        // networkId 가져오기
        const networkId = await web3.eth.net.getId();
        const CA = CounterContract.networks[networkId].address;

        const abi = CounterContract.abi;

        // Contract를 호출할 때 필요한 값들을 인자값으로 전달
        // 인자값 2개 , (abi, CA)
        const Deployed = new web3.eth.Contract(abi, CA); // 배포한 컨트랙트 정보 가져오기

        const count = await Deployed.methods.current().call();

        // 이벤트 구독
        // 백그라운드에서 돌아가는 코드.
        /**
         * eth.subscribe() 인자값 2개
         * 1. 'logs' 이벤트 구독
         * 2. 어느 컨트랙트 안에 있는 로그를 가져올 것인가. (해당 컨트랙트 안에 있는 로그만 추적)
         */
        // subscribe() : 구독하겠다.
        // on() : 받겠다.
        // 'logs' 이벤트가 발동할 때마다 on()에 있는 콜백함수 발동
        web3.eth.subscribe('logs', { address: CA }).on('data', (log) => {

            // decodeLog() 인자값
            // 1. 받아온 데이터를 어떤 형태로 파싱할 것인지
            //    type은 Solidity 쪽에서 선언한 타입 작성 (받는 쪽에서는 string으로 파싱)
            //    name은 이름을 지정해주는 것 (받을 이름)
            // 2. 파싱할 데이터
            const params = [{ type: 'uint256', name: 'count' }];
            const value = web3.eth.abi.decodeLog(params, log.data); // 반환값 Object
            // emit 한 데이터가 여러개라면 반환값의 형태는 배열 안의 Object
            // 여러 데이터가 있을 경우 인덱스 혹은 지정한 name으로 구분

            setCount(value.count);
        });
        // data : '0x0000000000000000000000000000000000000000000000000000000000000002'
        // uint256 공간 안에 count 상태변수 값만큼 넣어놓은 것

        setCount(parseInt(count));
        setDeployed(Deployed);
    })();
}, []);

web3.eth.subscribe( ) 메소드를 통해 'logs' 이벤트를 구독하게 되고 이더리움 네트워크 내 스마트 컨트랙트가 실행되어 로그 기록이 찍힐 때마다 .on( ) 메소드 안의 콜백 함수가 실행된다. subscribe( ) 메소드의 두번째 인자값으로는 CA 정보가 담겨있는 객체를 전달해주게 되는데 이는 어떤 스마트 컨트랙트의 'logs' 이벤트를 구독할 것인지를 명시해주기 위함이다.

 

이후 const value = web3.eth.abi.decodeLog(params, log.data) 에 의해 전달 받은 log 데이터를 디코딩 해주게 된다. 이 때 params 변수에 [{ type: 'uint256', name: 'count' }] 값을 할당해서 decodeLog( ) 메소드의 첫번째 인자값으로 전달하게 되는데 type 속성값에는 어떤 타입의 로그 데이터를 파싱할 것인지를 명시해주고 name 속성값에는 로그 데이터를 구분하기 위한 이름을 넣어주면 된다. 로그 데이터 안에 여러 개의 데이터들이 있을 경우 name 속성값으로 지정해준 이름을 이용해 구분 지을 수 있게 하기 위함이다. 배열 안에 객체를 담아서 params 값으로 할당해준 이유 역시 로그 데이터에 여러 데이터들이 있을 경우 각각의 데이터에 해당하는 정보를 객체 형태로 배열 안에 담아야 하기 때문이다. 마지막으로 setCount(value.count) 를 통해 count 값의 상태를 업데이트 해주면 블록체인 네트워크 상에 있는 노드들 전부 스마트 컨트랙트의 상태변수 업데이트 여부를 인지할 수 있게된다. 

 

아래는 스마트 컨트랙트에 이벤트를 등록한 이후의 프론트쪽 <Counter /> 컴포넌트의 코드이다.

/* src/components/ 디렉토리 Counter.jsx 파일 */

import React, { useEffect, useState } from 'react';
import CounterContract from '../contracts/Counter.json';

const Counter = ({ web3, account }) => {
    const [count, setCount] = useState(0);
    const [deployed, setDeployed] = useState(null);

    const increment = async () => {
        await deployed.methods.increment().send({ from: account });
    };

    const decrement = async () => {
        await deployed.methods.decrement().send({ from: account });
    };

    useEffect(() => {
        (async () => {
            if (deployed) return;

            const networkId = await web3.eth.net.getId();
            const CA = CounterContract.networks[networkId].address;
            const abi = CounterContract.abi;

            const Deployed = new web3.eth.Contract(abi, CA);

            const count = await Deployed.methods.current().call();

            web3.eth.subscribe('logs', { address: CA }).on('data', (log) => {
                const params = [{ type: 'uint256', name: 'count' }];
                const value = web3.eth.abi.decodeLog(params, log.data);

                setCount(value.count);
            });

            setCount(parseInt(count));
            setDeployed(Deployed);
        })();
    }, []);

    return (
        <div>
            <h2>Counter : {count}</h2>
            <button onClick={() => increment()}>+</button>
            <button onClick={() => decrement()}>-</button>
        </div>
    );
};

export default Counter;

 

 

 

2. 백엔드에서 트랜잭션 생성하기 

이번에는 백엔드 쪽에서 트랜잭션 객체를 만들어서 프론트엔드에 전달하는 부분을 구현해보고자 한다. 프론트 쪽에서는 axios를 사용해서 백엔드 쪽에 트랜잭션을 발생시킬 계정 정보만을 전달한 다음 실질적인 트랜잭션 객체는 백엔드에서 만들어주는 것이다. 백엔드는 트랜잭션 객체를 만들어서 다시 프론트 쪽에 전송하고 프론트는 전달받은 트랜잭션 객체를 가지고 연결된 메타마스크를 이용해 트랜잭션을 발생시킨다. 즉, 프론트는 전달받은 트랜잭션 객체에 메타마스크를 통해 서명을 추가하고 트랜잭션을 발생시켜주기만 하는 것이다.

 

우선 백엔드 쪽 코드를 살펴보도록 하자.

const express = require('express');
const app = express();
const cors = require('cors');
const Web3 = require('web3');
const web3 = new Web3(new Web3.providers.HttpProvider('http://127.0.0.1:8545'));
const CounterContract = require('./contracts/Counter.json');

app.use(
    cors({
        origin: true,
        credentials: true,
    }),
);
app.use(express.json());

app.post('/api/increment', async (req, res) => {
    const { from } = req.body;

    const nonce = await web3.eth.getTransactionCount(from);
    const networkId = await web3.eth.net.getId();
    const CA = CounterContract.networks[networkId].address;
    const abi = CounterContract.abi;

    // abi 파일
    // increment().encodeABI() : 원본데이터로 변환 (바이트 코드로 변환)
    const deployed = new web3.eth.Contract(abi, CA);
    const data = await deployed.methods.increment().encodeABI();

    let txObject = {
        nonce,
        from,
        to: CA,
        data,
    };

    res.json(txObject);

    /**
     * data 부분에 들어가는 값
     * Smart Contract 함수에 대한 내용
     * 스마트 컨트랙트 함수를 실행시킬 수 있는 어떠한 값
     * 함수를 실행시킬 수 있는 메세지 작성
     */
});

app.listen(4000, () => {
    console.log('back server onload');
});

트랜잭션 객체를 만들어 줄 때 abi 내용과 CA 정보가 필요하기 때문에 백엔드 쪽에도 Counter 스마트 컨트랙트에 대한 정보가 들어있는 파일인 Counter.json 파일이 필요하다. 따라서 const CounterContract = require('./contracts/Counter.json') 으로 CounterContract 변수에 Counter.json 파일의 내용을 할당해주었다.

 

'/api/increment' 라우터로 post 요청이 들어왔을 때 전달받은 계정 정보를 이용해 nonce값을 만들어주고 스마트 컨트랙트 정보가 담겨 있는 CounterContract 에서 CA 값과 abi 내용을 가져온 다음 const deployed = new web3.eth.Contract(abi, CA) 로 deployed 변수에 Contract 인스턴스를 할당하였다. 

 

다음으로 data 속성값을 만들어주는 과정을 주의깊게 살펴봐야 한다.

const data = await deployed.methods.increment().encodeABI();

deployed.methods.increment( ).encodeABI( ) 에 의해 스마트 컨트랙트 안에 존재하는 increment( ) 함수를 바이트 코드로 인코딩해주는 과정을 거치게 된다. 이더리움 네트워크 상에 배포된 스마트 컨트랙트는 바이트 코드 형태로 존재하기 때문에 컨트랙트 함수를 실행시킬 트랜잭션 객체를 만들어줄 때 data 속성값으로 어떠한 함수를 실행시킬 것인지 바이트 코드 형태로 속성값을 만들어줘야 한다.

 

마지막으로 txObject 객체 안에 nonce, from, to, data 속성값을 넣어준 다음 res.json( txObject ) 로 프론트에 트랜잭션 객체를 전달해주면 된다. 이제 프론트 쪽 코드를 살펴보자.

const increment = async () => {
    const response = await axios.post('http://localhost:4000/api/increment', {
        from: account,
    });

    await web3.eth.sendTransaction(response.data);
};

(+) 버튼을 클릭했을 때 호출되는 increment( ) 함수 안에서 axios 를 사용해 백엔드 쪽 '/api/increment' 라우터로 post 요청을 보내고 있는 것을 볼 수 있다. 이제 프론트는 해당 요청을 통해 백엔드에서 만들어준 트랜잭션 객체를 전달 받고 web3.eth.sendTransaction( response.data )에 의해 프론트와 연결된 메타마스크에서 서명과 함께 트랜잭션을 발생시켜 주기만 하면 되는 것이다.

 

 

참고로 트랜잭션의 종류에 대해 정리해보면 다음과 같다.

  • 단순히 코인 송금 용도의 트랜잭션
  • 스마트 컨트랙트 배포 관련 트랜잭션
  • 스마트 컨트랙트 실행 관련 트랜잭션

송금 용도의 트랜잭션과 스마트 컨트랙트 배포용 트랜잭션은 transactionReceipt 객체 안에 contractAddress가 존재하는지의 유무로 구분할 수 있다. (CA가 존재하지 않는다면 송금 용도, 존재한다면 스마트 컨트랙트 배포 용도) 그리고 transaction 객체 안의 input 속성값으로 컨트랙트 함수의 인코딩 값이 있는지 없는지를 통해 스마트 컨트랙트 배포용 트랜잭션과 스마트 컨트랙트 실행용 트랜잭션을 구분해 줄 수 있다.