Dapps
December 18, 2022

dApps | подключаем metamask к смарт-контракту через сайт

Привет в этом ряду статей мы будем говорить про связывание наших смарт контрактов с фронтендом. Думаю вам будет интересно как мы можем заставить наши функции выполняться при нажатии кнопок на сайте. Сразу скажу, что эта статья очень объёмная в и содержит очень много информации, сразу не будет все понятно. Но не чего страшного в этом нет. Со временем все уложиться.

Весь материал брал у этого классного парня с ютуба @IlyaBodrovKrukowski. Если хотите посмотрите. Но там достаточно сложно и не понятно для новичка. Я же попытался упросить его материал.(надеюсь получилось). А так советую всем посмотреть его ролики.

Итак. До этого, при создании смарт контрактов, мы использовал remix, но сегодня мы будем использовать visual studio code. Если у вас нет vs code, то скачайте.

Создание проекта

Мы будем создавать проект hardhat. Он нам нужен, чтоб создать свою локальную сеть эфира для быстрой разработки смарт-контрактов.

Первое что нам понадобиться это node.js. Если у вас нет его, то скачать тут тык

После того как вы установили node.js нужно создать проект hardhat.
Создаем папку (у меня это new contract)

Теперь откроем наш терминал и зайдем в нашу папку при помощи команды cd и путь к папке (у меня cd D:\solidity\new contract).

После того как мы зашли в нашу папку пишем команды подряд:
npm init
npm install --save-dev hardhat
npx hardhat

Думаю тут объяснять не нужно подробно. Там все написано. Просто вводим подряд команды.

Единственное что скажу, это после последней команды вам будет предложен выбор проекта. Выбираем первый (create javascript project). И потом хотите ли добавить .gitignore, выбираем ДА (может пригодиться когда то).

Вот так у вас должно быть. Если не получилось то смотрите документацию тут тык

Важно!

После того как был создан проект вводим команду которую нам предлагают

npm install --save-dev и так далее. Она прям в консоли будет написана. Не забудьте про нее а то не будет работать ни чего дальше!!!

Пишем смарт-контракт и deploy скрипт

Теперь открываем vs code и выбираем нашу папку.

Теперь папку contracts и удаляем все из файла Lock.sol. И пишем наш небольшой и простой код.

Тут ни чего особенного. Просто устанавливаем овнера контракта и возвращаем адрес.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
contract Lock {
    address public owner;     
    constructor() {         
        owner = msg.sender;     
    }     
    modifier onlyOwner() {         
        require(msg.sender == owner, 'not an owner');
        _;     
    }     
    function getAddress() public view onlyOwner returns(address){
        return msg.sender;
    } 
}

Теперь самое главное. Для того чтоб мы могли взаимодействовать с нашим смарт контрактом после его деплоя. Нам нужно использовать api смарт-контракта. Тут изобретать велосипед не нужно в интернете много примеров.

Заходим в папку scritps и видим файл deploy.js. Удаляем все от туда и вставляем вот этот код

const hre = require('hardhat');
const ethers = hre.ethers;
const fs = require('fs');
const path = require('path');
async function main() {
  if (network.name === "hardhat") {
      console.warn("You are trying to deploy a contract to the Hardhat Network, which" +
       "gets automatically created and destroyed every time. Use the Hardhat" +
        " option '--network localhost'"    
      );  
   }
   
  const [deployer] = await ethers.getSigners()
  
  console.log("Deploying with", await deployer.getAddress())
  
  const demo = await ethers.getContractFactory("Lock", deployer)
  
  const contract = await demo.deploy(  
    
   )  
   
  await contract.deployed()  
  
  saveFrontendFiles({
      Lock: contract
  })}
function saveFrontendFiles(contracts) {
  
    const contractsDir = path.join(__dirname, '/..', 'front/contracts')
    
    if(!fs.existsSync(contractsDir)) {
        fs.mkdirSync(contractsDir)
    
    }
  Object.entries(contracts).forEach((contract_item) => {
      const [name, contract] = contract_item
      if(contract) {
            fs.writeFileSync(
                path.join(contractsDir, '/', name + '-contract-address.json'),
                JSON.stringify({[name]: contract.address}, undefined, 2)
       )}
  const ContractArtifact = hre.artifacts.readArtifactSync(name)
  fs.writeFileSync(     
  path.join(contractsDir, '/', name + ".json"),
  JSON.stringify(ContractArtifact, null, 2)
    )
  })
}
main()
  .then(() => process.exit(0))
  .catch((error) => {
      console.error(error)
      process.exit(1)
   })

Принцип работы простой. У нас будут аккаунты, которые предоставит hardhat и мы берем первый из списка и просто вызываем метод deploy который собирает наш проект. Ну а дальше функция, которая просто копирует json файлы нам в фронтенд. Не вижу смысла на этом заострять внимание, потому что мы сегодня не про hardhat. Кому интересно можно посмотреть документацию тык

Из важного:
const demo = await ethers.getContractFactory("Lock", deployer)
Слово Lock должно совпадать с названием контракта.

Теперь зайдем в файл hardhat.config.js и добавим код:

Это код до:
require("@nomicfoundation/hardhat-toolbox");
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {  
  solidity: "0.8.17",
};
Это код после:
require("@nomicfoundation/hardhat-toolbox");
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {  
  solidity: "0.8.17",
  networks: {    
    hardhat: {      
      chainId: 1337    
    }  
}};

Тут в общем это нужно из за того, что бывают ошибки с сетью. Просто добавили забыли.

Localhost8545

Если у вас нет локальной сети в метамаске, то добавляем:

Заходим в настройки networks и тут нажимаем add network.

Вас перекинет сюда и в поиске пишем localhost и после просто добавляем сеть. Все параметры подтянуться сами.

Создаём фронтенд

Теперь давайте создадим наш react.js проект, и запустим свой сервер, чтоб работать с метамаском. Заходим на сайт next.js .

Выбираем один из вариантов установки.

npx create-next-app@latest

yarn create next-app

pnpm create next-app

Я беру первый вариант. Открываем консоль, заходим в нашу директорию и вводим команду. Если предложит TypeScript project то отказываемся.

Выбираем название файла, где будет проект. Я назвал front. Потом ждем установку.

Дальше в нашей папке front создаем папку contracts

Теперь мы открываем новую консоль и заходим в наш проект.
Вводим команду:
npx hardhat node

Запуститься локальный блокчейн:

Тут мы можем добавить пару аккаунтов в метамаск.

Выбираем import account и вставляем приватный ключ любого акка из консоли. Желательно импортировать первый акк, так как он является овнером контракта.

У вас будет акк с 10000 эфира.

Теперь снова открываем новую консоль, a ту старую с нодой не закрываем и заходим в проект через cd.
Вводим команду:
npx hardhat run scripts\deploy.js --network localhost

Если все сделали правильно то должно быть так

Теперь переходим в папку front (cd front) в консоли
вводим команду:
npm run dev
запуститься сервер.

Переходим на сайт localhost:3000 и видим это.

Теперь все удаляем из файла index.js и будем писать свой код в index.js:

import React, { Component } from 'react'
import { ethers } from 'ethers'
import styles from '../styles/Home.module.css'
import { ConnectWallet } from '../components/ConnectWallet'
import LockAddress from '../contracts/Lock-contract-address.json'
import LockArtifact from '../contracts/Lock.json'

const HARDHAT_NETWORK_ID = '1337'

export default class Game extends Component {
       
    constructor(props) {        
    
       super(props)      
       
       this.textInput = React.createRef();
       
       this.initialState = {        
           selectedAccount: null,        
           networkError: null,        
           balance: null,      
       }
            
      this.state = this.initialState
      
    }          
      
    _connectWallet = async () => {
    
        if(window.ethereum === undefined) {        
            this.setState({          
              networkError: 'Please install Metamask!'        
            })        
         return      
         }        
        const [selectedAddress] = await window.ethereum.request({
        method: 'eth_requestAccounts'      
        })            
        if(!this._checkNetwork()) { return }        
            this._initialize(selectedAddress)        
            window.ethereum.on('accountsChanged', ([newAddress]) => {        
        if(newAddress === undefined) {          
            return this._resetState()        
        }          
        this._initialize(newAddress)      
        })        
        window.ethereum.on('chainChanged', ([networkId]) => {        
            this._resetState()      
        })          
        const newBalance = (await this._provider.getBalance(        
        selectedAddress      
        )).toString()          
     }    
    _checkNetwork() {
        if (window.ethereum.networkVersion === HARDHAT_NETWORK_ID)
         { return true }
           this.setState({       
             networkError: 'Please connect to localhost:8545'      
           })        
         return false    
    } 
    async _initialize(selectedAddress) {            
        this._provider = new ethers.providers.Web3Provider(window.ethereum)
        this.Lock = new ethers.Contract(        
            LockAddress.Lock,        
            LockArtifact.abi,        
            this._provider.getSigner(0)      
        )           
        this.setState({        
            selectedAccount: selectedAddress      
        }, async () =>         
        await this.updateBalance()    
        )}  
           
     async updateBalance() {      
          const newBalance = (await this._provider.getBalance(        
          this.state.selectedAccount      
          )).toString()        
          this.setState({        
              balance: newBalance      
          })    
     }  
    _resetState() {      
        this.setState(this.initialState)    
    }          
    _dismissNetworkError = () => {    
      this.setState({      
        networkError: null     
    })   
   }         
  render() {    
      if(!this.state.selectedAccount) {           
           return <>                 
               <div className={styles.front}>           
                <ConnectWallet 
                 connectWallet={this._connectWallet}
                 networkError={this.state.networkError}
                 dismiss={this._dismissNetworkError}/>
               </div >                           
           </>       
       }    
       return(      
       <>      
          {this.state.balance &&              
          <p className={styles.balance}>
          balance: {ethers.utils.formatEther(this.state.balance)} ETH</p>} 
       </>    )                         
    } 
}

Можете уже вставить это в index.js, дальше расскажу что мы тут делаем и как это работает.

import React, { Component } from 'react'
import { ethers } from 'ethers'
import styles from '../styles/Home.module.css'
import { ConnectWallet } from '../components/ConnectWallet'
import LockAddress from '../contracts/Lock-contract-address.json'
import LockArtifact from '../contracts/Lock.json'

Мы импортируем бибилотеки react, ethers для работы проекта.
Потом styles - для стилей css чтоб было красиво (если вы хотите)
ConnectWallet - компонент который мы напишем позже.
LockAddress - это адрес самрт контракта
LockArtifact - это наш abi смарт контракта

Все пути к файлам смотрите, чтоб совпадали с вашими, где находятся файлы!

import React, { Component } from 'react'
import { ethers } from 'ethers'
import styles from '../styles/Home.module.css'
import { ConnectWallet } from '../components/ConnectWallet'
import LockAddress from '../contracts/Lock-contract-address.json'
import LockArtifact from '../contracts/Lock.json'

const HARDHAT_NETWORK_ID = '1337'

export default class Game extends Component {
       
    constructor(props) {        
    
       super(props)      
      
       this.initialState = {        
           selectedAccount: null,        
           networkError: null,        
           balance: null,      
       }
            
      this.state = this.initialState
      
    }
    
}      

Так как мы создаем класс, то нам нужно конструктор где будут храниться глобальные переменные. Чтоб узнать больше смотрите документацию react.js
У нас это:
Аккаунт: selectedAccount
сообщение о непредельной сети: networkError
баланс аккаунта: balance
null означает: когда мы заходим на сайт у нас нет инфы про наши данные.

     _connectWallet = async () => {
    
        if(window.ethereum === undefined) {        
            this.setState({          
              networkError: 'Please install Metamask!'        
            })        
         return      
         }        
        const [selectedAddress] = await window.ethereum.request({
        method: 'eth_requestAccounts'      
        })            
        if(!this._checkNetwork()) { return }        
            this._initialize(selectedAddress)        
            window.ethereum.on('accountsChanged', ([newAddress]) => {        
        if(newAddress === undefined) {          
            return this._resetState()        
        }          
        this._initialize(newAddress)      
        })        
        window.ethereum.on('chainChanged', ([networkId]) => {        
            this._resetState()      
        })          
               
        } 

Наша первая функция:

_connectWallet - подключаем кошелек. async означает что вызов асинхронный, так как нам нужно обращаться к блокчейну и это не моментально.

Внутри функции делаем ряд проверок:
Есть ли у нас метамаск вообще, далее мы выбираем аккаунт метамаска и вызываем для этого метод 'eth_requestAccounts'. Дальше смотрим наша ли сеть стоит. Если наша ,то вызываем функцию _initialize, ее напишем позже. Если мы меняем адрес аккаунта в метамаске, то смотрим есть ли аккаунт и вызываем снова функцию _initialize, но с новым аккаунтом, который выбрали, и последнее. При смене сети мы cбрасываем все по дефолту при помощи _resetState, ее потом тоже напишем.

Надеюсь примерно понятно что мы делаем.

_checkNetwork() {
        if (window.ethereum.networkVersion === HARDHAT_NETWORK_ID)
         { return true }
           this.setState({       
             networkError: 'Please connect to localhost:8545'      
           })        
         return false    
    } 

_checkNetwork уже проще, тут просто проверка, является ли наша сеть, сетью hardhat и все. Если да, то true, если нет, то вернем фразу об ошибке.

Тут очень важная функция _initialize.

async _initialize(selectedAddress) {            
        this._provider = new ethers.providers.Web3Provider(window.ethereum)
        this.Lock = new ethers.Contract(        
            LockAddress.Lock,        
            LockArtifact.abi,        
            this._provider.getSigner(0)      
        )           
        this.setState({        
            selectedAccount: selectedAddress      
        }, async () =>         
        await this.updateBalance()    
        )}  

this._provider мы обращаемся к блокчейну по факту через _provider мы и будем работать с блокчейном. (Это когда метамаск вылезает)
Теперь что такое this.Lock - это по факту мы подрубаемся к самарт-контракту, чтоб получать доступ к нашим функциям и всему остальному. Туда мы передаем 3 важные вещи. Это адрес нашего контракта, abi контракта и того, кто собирается взаимодействовать с смарт-контрактом. То есть наш выбранный аккаунт metamask

Ну и все потом при помощи setState сохраняем аккаунт в глобальную переменную и обновляем баланс.

 async updateBalance() {      
          const newBalance = (await this._provider.getBalance(        
          this.state.selectedAccount      
          )).toString()        
          this.setState({        
              balance: newBalance      
          })    
          }  

updateBalance - тут ни чего сложного. Просто берем наш баланс аккаунта через обращение к this._provider и переводим его из BigNumber в string

_resetState() {      
        this.setState(this.initialState)    
    }          
    _dismissNetworkError = () => {    
      this.setState({      
        networkError: null     
    })   
   }    

_resetState - обнуляет все наши параметры глобальных переменных

_dismissNetworkError - просто закрывает сообщение об ошибке.

Теперь последний этап, мы создаем два компонента:

connectWallet и NetworkErrorMessage в папке components, которую нужно создать.

код ConnectWallet:

import { NetworkErrorMessage } from "./NetworkErrorMessage"
export function ConnectWallet({ connectWallet, networkError, dismiss }) {
  return (    
      <>      
         <div>        
            {networkError && (          
               <NetworkErrorMessage             
                  message={networkError}             
                  dismiss={dismiss}           
               />        
             )}      
          </div>
          <p>Please connect your account...</p>      
          
        <button type="button" onClick={connectWallet}>        
          Connect Wallet      
       </button>    
     </>  )}

Тут ни чего особенного, просто либо выводим информацию об ошибке, либо выводим кнопку, при нажатии которой вызовется функция connectWallet, которую мы написали раньше.

NetworkErrorMessage код:

export function NetworkErrorMessage({ message, dismiss }) {
    return (      
       <div>        
       {message}  
             
       <button type="button" onClick={dismiss}> 
                
       <span aria-hidden="true">&times;</span> 
             
        </button>      
       </div>    
     ) 
}

Тут мы просто создаем кнопку, которая закрывает наше сообщение об ошибке сети.

Теперь код который выводиться на экран (render):

render() {    
      if(!this.state.selectedAccount) {           
           return <>                 
               <div className={styles.front}>           
                <ConnectWallet 
                 connectWallet={this._connectWallet}
                 networkError={this.state.networkError}
                 dismiss={this._dismissNetworkError}/>
               </div >                           
           </>       
       }    
       return(      
       <>      
          {this.state.balance &&              
          <p>
          balance: {ethers.utils.formatEther(this.state.balance)} ETH</p>} 
       </>    )                         
    } 
}

Если у нас не подключен аккаунт то выводим наш компонент connectWallet, где есть кнопка для подключения кошелька. Если же мы удачно подключили кошелек, то выводим на экран баланс нашего аккаунта. ethers.utils.formatEther(this.state.balance) - конвертирует баланс из wei в eth.

Ну вот и все теперь давайте посмотрим что у нас получилось.

На нашем сайте появилась кнопка connect Wallet. Давайте выберем не нашу сеть и нажмем на кнопку. Посмотрим что будет.

Видим сообщение об ошибке. Теперь давайте переключимся на нашу сеть .

Ура мы подключили метамаск к смарт контракту и вывели его баланс. Да это достаточно сложно. Нужно знать много всего, но разобраться возможно. В частности, чтоб понимать все еще лучше, нужно изучить react.js, так как весь форнтенд пишется на нем. Но если вы изучали javascript то у вас не будет больших сложностей с этим. Так же нужно по лучше разобраться с ethers.js, но тут все очень просто. Нужно только понимать когда и где что вызвать. В целом то, что мы сегодня сделали можно просто использовать в своих дальнейших проектах и не изобретать велосипед. Так как мы реализовали почти все важные моменты. Дальше больше. Если вы увидели какие-то не точности, то пишите и если что то не понятно или не работает, тоже пишите. Я попробую помочь.

tg: мой телеграмчик)

github:
этот проект на гит хабе
все файлы папки front в отдельном репозитории
репозиторий папки front