즐코

React프론트-메타마스크 연동 (ft.WEB3) 본문

BlockChain

React프론트-메타마스크 연동 (ft.WEB3)

YJLEE_KR 2022. 6. 30. 01:54

이번 포스팅에선 프론트단에서 사용자가 입력한 데이터를 메타마스크로 넘기고, 메타마스크에서 개인키로 서명을 대신해줘서 완성한 이 tx 데이터를 블록체인네트워크로 전달하는 방식의 트랜잭션을 간단하게 구현해본다. 물론 위의 그림과 달리 GETH가 아닌 테스트 용도의 가나쉬 네트워크를 이용할 것이다. 이렇게 서버가 없는 구조를 DAPP이라고 보면 된다.

 

보통 트랜잭션을 만들고 블록체인 네트워크 쪽에 던지는 방식은 아래와 같이 3가지의 방식이 있다고 한다.

 

1/ 애초에 백엔드 서버 없이 프론트에서 입력 데이터를 받아 바로 트랜잭션을 만들어 메타마스크로 넘기는 경우

2/ 쇼핑몰처럼 상품에 대한 정보를 연동해줘야하는 경우 백엔드/DB 서버가 필요한데, 이때 프론트가 백엔드에게 입력 데이터를 던져서 백엔드 자체에서 서명을 제외한 트랜잭션을 만들어주면 프론트가 그 트랜잭션 객체를 메타마스크로 던지는 경우

3/ 백엔드 DB 상에 개인키를 갖고있어서 다이렉트로 블록체인 네트워크에 보내는 경우 (개인키의 보안에 있어서 좋은 방법은 아니다)

 

프론트-메타마스크 연결

해당 포스팅에선 우선 프론트-지갑프로그램의 연결을 다루고자 한다. 

프론트는 리액트로 구축할 것이고 지갑프로그램은 메타마스크를 사용할 것이다. 

 

간만에 CRA를 설치해준다.

$ npx create-react-app front

동시에 가나쉬 네트워크도 실행시킨다. 이때, chain id 옵션을 따로 설정해준다. 

$ npx ganache-cli --chainId 7722

 

이제 프론트와 메타마스크를 연결은 어떻게 하는가?

이걸 이해하기 위해선 window.ethereum에 대해서 알아야한다.

 

window.ethereum

기존 window 객체에는 ethereum이라는 속성이 없다. 메타마스크를 설치함과 동시에 메타마스크에서 window 객체에 자신의 기능을 넣어둬서 메타마스크가 설치된 브라우저의 개발자도구에선 window.ethereum이 출력된다.

(다만 구글에선 개발자 도구에 window.ethereum을 쳐도 먹히지 않음) 

 

메타마스크가 이 window객체에 넣어둔 자신의 기능이란?

: 사용자의 이더리움 계정을 가져와주고, 사용자가 연결된 블록체인 네트워크 상에서 데이터를 읽어오고, 사용자가 트랜잭션을 일으킬 때 서명을 해서 트랜잭션을 일으키는 등의 일이다..

 

메타마스크가 설치된 브라우저에서의 window.ethereum 객체

따라서, window.ethereum의 존재유무로 메타마스크의 설치유무를 가릴 수 있기 때문에 프론트쪽에서 해당 window.ethereum이 null 값일 경우, 메타마스크를 설치하란 내용을 띄어주면 된다.

 

window.ethereum.request()

- 프론트에서 메타마스크에게 RPC 방식 요청을 보낼수있는 메소드이다.

- 인자로는 요청 method와 params 속성(필요하다면)을 가진 객체를 넣어준다. 

- 프로미스 객체를 반환하기 때문에 async-await 구문을 사용할 것

 

프론트에서 메타마스크에게 요청을 보내는데, 우선 현재 연결된 chain Id와 account를 가져오고자 한다.

여기서 chain Id는 이더리움 네트워크 상에서 네트워크 고유 식별자와 같다.

 

현재 연결된 네트워크와 계정은 메타마스크 팝업창 상단에서 각각 확인 가능하다.

 

 

가볍게 메소드 세가지 정도를 써보았다. 

 

- eth_chainId : 현재 메타마스크 상에서 연결된 네트워크의 chainId 값을 가져와준다.

이때, 위에서 가나쉬를 실행시킬때 chainId를 따로 설정해줬는데, 그 설정한 chain id값과 다른 값이 찍히길래 뭘까했더니, 메타마스크에서 가나쉬 네트워크를 추가할 때 네트워크 설정에서 이 chain Id를 위에서 입력한 chain id와 다르게 설정해줬었다. 메타마스크 자체는 위에서 설정한 가나쉬 네트워크 체인 아이디를 가리키지만, 메타마스크 설정 쪽에서 내가 다른 체인 아이디를 넣어놨기에 브라우저 상에선 그걸 출력해주는 것이었다. 그렇기 때문에 우측 상단 계정 버튼 클릭 -> 설정 -> 네트워크 -> 수정할 네트워크명을 클릭해서 체인 아이디를 변경해주면 해결!

- eth_requestAccounts : 메타마스크 상에서 계정이 연결되어있다면 현재 사용되고있는 계정을 가져오고, 연결이 안되어있으면 계정 연결 팝업이 뜨고 여기서 이제 프론트와 연결할 계정을 선택해주면 그 계정넘버가 찍힌다!

 

- wallet_addEthereumChain : 메타마스크에 연결할 네트워크를 추가해준다. 

타겟 체인 아이디를 정해놓고, 현재 연결된 네트워크의 체인아이디가 그 타겟 체인 아이디와 다르다면 네트워크를 추가해주는 방식을 썼다.

따로 메타마스크에서 네트워크 추가를 해줘도 되지만 사용자가 해당 프론트에 접속했을 때 바로 특정 네트워크를 추가하고 접속하게끔 도와주기 위해서 써본다. 네트워크 추가 시 넣어주는 항목들을 속성으로 하는 네트워크 객체를 따로 만들어서 params로 넣어준다. 이렇게 해서 실행하면 아래와 같이 네트워크를 추가할지 물어보고, 현재 연결중인 네트워크에서 전환할 것인지도 물어본다.

 

 

컴포넌트가 첫렌더링 될때 (componentDidMount) 네트워크 설정 및 지갑에 연결된 체인아이디, 계정을 가져오게끔 useEffect 훅을 사용하자. 그리고 해당 useWeb3 훅은 연결된 지갑계정인 account를 리턴하도록 해주자. 

// front/src/hooks/useWeb3.js

import { useEffect, useState } from "react";

const useWeb3 = () => {
    const [account, setAccount] = useState(null);
    
    const getChainId = async() => {
    	const chainId = await window.ethereum.request({
            method: "eth_chainId",
        });
        return chainId; // 메타마스크 네트워크를 바꾸면 이 chainId도 바뀐다.
    };
    
    const getReqAccts = async() => {
    	const acct = await window.ethereum.request({
            method: "eth_requestAccounts",
        });
        return acct; // 메타마스크 계정 연결 팝업이 뜨고 프론트와 연결할 계정을 선택해주면 계정#가 찍힌다
    };
    
    const addNetwork = async (chainId) => {
      const network = {
        chainId,
        chainName: "yjGanache",
        rpcUrls: ["http://127.0.0.1:8545"],
        nativeCurrency: {
          name: "Ethereum",
          symbol: "ETH",
          decimals: 18,
        },
      };
    await window.ethereum.request({
      method: "wallet_addEthereumChain",
      params: [network],
    });
  };
    // useEffect 훅스에는 async 구문을 쓰지 못하므로 함수 init으로 따로 뺌
    useEffect(()=>{
     const init = async() => {
     try{
        const targetChainId = "0x1e2a";
        const chainId = await getChainId();
        if (targetChainId !== chainId) {
          addNetwork(targetChainId);
        }
        const [account] = await getReqAccts(); // 결과물이 배열로 떨어져서 구조분해할당
        setAccount(account);
      }catch(e){
        console.error(e.message);
       }
     }
     // 메타마스크가 설치되있다면,
     if(window.ethereum){
     	init();
     }else{
     // 설치 안 된사람에게 실행할 부분
     }
    },[])
    
    return [account];
}

export default useWeb3;

앞서 만든 useWeb3 훅에서 account를 리턴하므로, 이를 가져와서 지갑의 로그인 유무를 결정해준다. 

연결된 account가 존재하면 로그인 상태이므로 그에 따른 화면을 렌더하고 반대일 경우엔 로그인해달란 화면을 렌더해준다.

// front/src/App.js

import useWeb3 from "./hooks/useWeb3";
import { useState, useEffect } from "react";

function App(){
    const [account] = useWeb3();
    const [isLogin, setIsLogin] = useState(false);
    
    useEffect(()=>{
    	if(account) setIsLogin(true);
    },[account]);
    
    if(!isLogin) return <div>메타마스크 로그인 후 사용해주세요</div>;
    return(
    <div>
      <div>
        <h2>{account}님 환영합니다.</h2>
      </div>
    </div>
    )
};

export default App;

 

WEB3 사용하여 메타마스크로 거래 발생시키기

web3 라이브러리를 설치해준다.

$ npm i web3

 

우선, web3는 내부적으로 여러 가지 모듈이 있는데, 이 모듈들은 브라우저에서 쓰는 게 아닌 노드js에서 쓰는 라이브러리들이다.

브라우저에서는 알 수 없는 라이브러리라 번들링이 안되서 에러가 터진다. 웹팩 설정 시 해당 라이브러리들을 안쓰는 걸로 설정해주거나, web3를 통째로 가져오는 게 아니라 아래와 같이 경량화한 web3 라이브러리를 가져오는 방식으로 해결한다.

import Web3 from "web3/dist/web3.min.js";

web3 사용을 위해 인스턴스를 생성해준다.

저번 포스팅에선 web3 인스턴스 생성시 인자로 provider 즉, 이더리움 클라이언트(노드)를 넣어주었는데, 이번엔 window.ethereum을 인자로 넣어주었다. window.ethereum은 위에서 정리한대로 window객체에 메타마스크가 자신의 기능을 수행하게끔 넣어둔 것이다. 이말인즉슨, window.ethereum은 메타마스크를 가리킨다. 그리고 우린 현재 트랜잭션을 메타마스크를 통해서 할 것이기때문에, 이 web3 인스턴스 생성시 인자값으로 window.ethereum을 넣게 되면 web3 라이브러리로 메타마스크에게 요청할수 있게 된다. 

 

또한 이 useWeb3() 훅은 account와 함께 이 web3 인스턴스도 리턴해준다. 왜냐하면 다른 컴포넌트 상에서 메타마스크로 요청을 보낼 경우가 생기면 이 useWeb3()만 가져와 web3 인스턴스를 사용해서 메타마스크에 쉽게 요청을 보낼 수 있기 때문이다. 

이렇게 하지 않으면 각 컴포넌트마다 화면에 렌더될때 저 web3 인스턴스를 생성하게끔 해줘야한다..

// front/src/hooks/useWeb3.js

import { useEffect, useState } from "react";
import Web3 from "web3/dist/web3.min.js";

const useWeb3 = () => {
    const [account, setAccount] = useState(null);
    const [web3, setWeb3] = useState(null);
    
    const getChainId = async() => {
    	const chainId = await window.ethereum.request({
            method: "eth_chainId",
        });
        return chainId; // 메타마스크 네트워크를 바꾸면 이 chainId도 바뀐다.
    };
    
    const getReqAccts = async() => {
    	const acct = await window.ethereum.request({
            method: "eth_requestAccounts",
        });
        return acct; // 메타마스크 계정 연결 팝업이 뜨고 프론트와 연결할 계정을 선택해주면 계정#가 찍힌다
    };
    
    const addNetwork = async (chainId) => {
      const network = {
        chainId,
        chainName: "yjGanache",
        rpcUrls: ["http://127.0.0.1:8545"],
        nativeCurrency: {
          name: "Ethereum",
          symbol: "ETH",
          decimals: 18,
        },
      };
    await window.ethereum.request({
      method: "wallet_addEthereumChain",
      params: [network],
    });
  };
    // useEffect 훅스에는 async 구문을 쓰지 못하므로 함수 init으로 따로 뺌
    useEffect(()=>{
     const init = async() => {
     try{
        const targetChainId = "0x1e2a";
        const chainId = await getChainId();
        if (targetChainId !== chainId) {
          addNetwork(targetChainId);
        }
        const [account] = await getReqAccts(); // 결과물이 배열로 떨어져서 구조분해할당
        const web3 = new Web3(window.ethereum);
    
        setAccount(account);
        setWeb3(web3);
        
      }catch(e){
        console.error(e.message);
       }
     }
     // 메타마스크가 설치되있다면,
     if(window.ethereum){
     	init();
     }else{
     // 설치 안 된사람에게 실행할 부분
     }
    },[])
    
    return [account, web3];
}

export default useWeb3;

useWeb3() 리턴값인 web3를 받아와서 이를 이용하여 트랜잭션을 만들어본다. 

이 때, 로그인한 사용자에겐 트랜잭션을 위한 폼이 보이게끔 만들어준다.

 

1/ 폼을 입력하고 submit 을 하면 해당 폼의 내용을 가지고 tx 객체를 만든 후 (물론 서명 제외)

2/ web3.eth.sendTransaction(tx)로 쉽게 거래를 만들수있다!

 

+ 각 계정별 잔고 보여주기

또한, 각 계정 클릭시 balance 조회도 가능하게끔 화면이 첫 렌더되었을 때 및 계정이 바뀔 때마다 잔고(상태)가 바뀌게끔 해줬다.

 

우선 balance를 상태로 잡았다. web3 라이브러리의 엥간한 메소드들은 거의 다 프로미스 객체를 반환하기 때문에 async-await구문을 써줘야하는데, useEffect의 콜백함수 상에는 async를 쓰지 못해서 init 함수를 따로 빼서 그 안에서 web3를 사용하여 밸런스를 요청하고 (web3.eth.getBalance()) 이 init 함수를 화면 첫 렌더 시와 계정상태가(account) 바뀔때마다 이 init함수를 호출하는 방식으로 갔다. 

 

이때, web3에 왜 '?' 를 썼냐면 로그인하지 않은 상태에선 화면이 렌더될때 web3를 가져올 수 없기 때문에, web3는 null일 것이고 에러가 날 것이므로 이를 대비하기 위해 썼다고 보면 된다. 

// front/src/App.js

import useWeb3 from "./hooks/useWeb3";
import { useState, useEffect } from "react";

function App(){
    const [account, web3] = useWeb3();
    const [isLogin, setIsLogin] = useState(false);
    const [balance, setBalance] = useState(0);

  useEffect(() => {
    const init = async () => {
      // ? 를 씀으로써 web3가 null값일때의 에러를 대비해줌
      const balance = await web3?.eth.getBalance(account);
      setBalance(balance / 10 ** 18);
    };
    if (account) setIsLogin(true);
    init();
  }, [account]);

  const handleSubmit = async (e) => {
    e.preventDefault();
    const tx = {
      from: account,
      to: e.target.recipient.value,
      value: web3.utils.toWei(e.target.amount.value, "ether"),
    };
    await web3.eth.sendTransaction(tx);
  };
  if (!isLogin) return <div>메타마스크 로그인 후 사용해주세요</div>;
  return (
    <div>
      <div>
        <h2>{account}님 환영합니다.</h2>
        <div>Balance : {balance} ETH </div>
      </div>
      <div>
        <form onSubmit={handleSubmit}>
          <input type="text" id="recipient" placeholder="받을 계정" />
          <input type="number" id="amount" placeholder="보낼 금액" />
          <input type="submit" value="전송" />
        </form>
      </div>
    </div>
  );
}

export default App;

 

 

Comments