Web3 приложение Twitter на React.js + Solidity | часть 2

822f678720311a077bbf67c836de151f.png

Hello, в первой части был подготовлен проект, подключены кошельки и написан backend на Solidity, значит пришло время писать frontend на React.

Проект далёк от продакшена и является простым примером для новичков, предназначенным для демонстрации взаимодействия с смарт-контрактом через веб-приложение.

Возвращаемся в главную папку проекта web3, в которой создаем проект на реакте.

$ npx create-react-app client

98277556e02bb16010dd8f1169789df8.png

Далее нужно поставить две библиотеки: react-router-domиweb3. Первая нужна для перехода между страницами, а вторая для работы со смарт-контрактом.

cd client
npm i react-router-dom, web3

Если используете yarn:

cd client
yarn add web3
yarn add react-router-dom

Видим наши библиотеки в зависимостях, значит можем начинать.

f917f6df795553cfa46f74d45b3daefd.png

Подготовка клиента

Начнем с удаления лишних файлов.

Удаляем

Удаляем

Переходим в файл index.js и импортируем BrowserRouter из библиотеки react-router-dom, также оборачиваем компонент App в роутер. Удаляем лишние комментарии в коде и чистим импорты.

6787865dc177cfee86068a4a0548e1a6.png

Далее создаем две папки: config и contexts. В папке config создаем два файла: abi.json (в нем будет abi нашего смарт-контракта)и contract.js (в нем будет адрес нашего смарт-контракта).
P.S: адрес контракта можно посмотреть в 1 блоке в Ganache .

ABI — в контексте смарт-контрактов — это как бы язык общения между различными программами или компонентами программного обеспечения.

В случае с смарт-контрактами, ABI представляет собой набор правил и форматов данных, которые определяют, как внешние программы могут взаимодействовать с этими контрактами. Это включает в себя формат передачи данных, типы данных и структуру функций.

В папке contexts создаем файл ContractContext.js в нем мы опишем подключение к кошельку MetaMask и смарт-контракту, а также возвращаемые переменные, чтобы иметь к ним доступ на любой странице сайта, просто вызвав useContract.

fadee17b2396ae6265bf6baccc0f7ab7.png

В файле contract.js будет всего одна строчка:

export const contract_address = "адрес вашего смарт-контракта";

Теперь нужно найти ABI нашего контракта, чтобы вставить его в файл abi.json. Для этого переходим в папку contracts с нашим бэкэндом и открываем папку build/contarcts и в ней файл Twitter.json. В нем находим abi и полностью копируем.

a035215e688d485aa658133c434a2c62.png

Просто вставляем скопированный текст в наш файл abi.json. Ниже abi для контракта из первой части.

Hidden text

[
    {
      "inputs": [],
      "stateMutability": "nonpayable",
      "type": "constructor"
    },
    {
      "inputs": [
        {
          "internalType": "string",
          "name": "_username",
          "type": "string"
        },
        {
          "internalType": "string",
          "name": "_password",
          "type": "string"
        }
      ],
      "name": "Registration",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    },
    {
      "inputs": [
        {
          "internalType": "string",
          "name": "_password",
          "type": "string"
        }
      ],
      "name": "Login",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    },
    {
      "inputs": [],
      "name": "Logout",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    },
    {
      "inputs": [
        {
          "internalType": "string",
          "name": "_text",
          "type": "string"
        }
      ],
      "name": "AddTwitt",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    },
    {
      "inputs": [
        {
          "internalType": "address",
          "name": "_user",
          "type": "address"
        }
      ],
      "name": "UserTwitts",
      "outputs": [
        {
          "components": [
            {
              "components": [
                {
                  "internalType": "address",
                  "name": "login",
                  "type": "address"
                },
                {
                  "internalType": "string",
                  "name": "password",
                  "type": "string"
                },
                {
                  "internalType": "string",
                  "name": "username",
                  "type": "string"
                },
                {
                  "internalType": "string",
                  "name": "avatar",
                  "type": "string"
                }
              ],
              "internalType": "struct Twitter.User",
              "name": "author",
              "type": "tuple"
            },
            {
              "internalType": "string",
              "name": "text",
              "type": "string"
            },
            {
              "internalType": "uint256",
              "name": "likes",
              "type": "uint256"
            },
            {
              "internalType": "uint256",
              "name": "createdTime",
              "type": "uint256"
            }
          ],
          "internalType": "struct Twitter.Twitt[]",
          "name": "",
          "type": "tuple[]"
        }
      ],
      "stateMutability": "view",
      "type": "function",
      "constant": true
    },
    {
      "inputs": [
        {
          "internalType": "address",
          "name": "_user",
          "type": "address"
        }
      ],
      "name": "CheckRegistration",
      "outputs": [
        {
          "internalType": "bool",
          "name": "",
          "type": "bool"
        }
      ],
      "stateMutability": "view",
      "type": "function",
      "constant": true
    },
    {
      "inputs": [
        {
          "internalType": "address",
          "name": "_user",
          "type": "address"
        }
      ],
      "name": "GetUser",
      "outputs": [
        {
          "components": [
            {
              "internalType": "address",
              "name": "login",
              "type": "address"
            },
            {
              "internalType": "string",
              "name": "password",
              "type": "string"
            },
            {
              "internalType": "string",
              "name": "username",
              "type": "string"
            },
            {
              "internalType": "string",
              "name": "avatar",
              "type": "string"
            }
          ],
          "internalType": "struct Twitter.User",
          "name": "",
          "type": "tuple"
        }
      ],
      "stateMutability": "view",
      "type": "function",
      "constant": true
    },
    {
      "inputs": [
        {
          "internalType": "string",
          "name": "_avatar",
          "type": "string"
        }
      ],
      "name": "UpdateUser",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    }
  ]

Далее пишем ContractProvider, надеюсь вы хотя бы немного знакомы с js и react, но в любом случае код будет простым.

В первую очередь подтягиваем web3 библиотеку и наш адрес контракта вместе с abi.

Далее создаем контекст ContractContext.

Контекст используется для передачи данных вниз по дереву компонентов без явной передачи пропсов.

397c3ca911a475d9a5bbab6e5cf6dd1c.png

В самом компоненте ContractProvider объявляем несколько состояний.

  1. contract — содержит экземпляр смарт-контракта.

  2. account — содержит адрес пользователя вошедшего в аккаунт.

  3. accounts — содержит адреса всех пользователей.

  4. balance — содержит баланс пользователя вошедшего в аккаунт.

const [contract, setContract] = useState(null);
const [account, setAccount] = useState(null);
const [accounts, setAccounts] = useState([]);
const [balance, setBalance] = useState();

Переходим к написанию функцию подключения аккаунта пользователя к приложению. В ней мы создаем экземпляр объекта web3 для взаимодействия с Ethereum. Подключение идет через MetaMask или локальный сервер с адресом и портом, которые мы указывали в Ganache.

Если расширение MetaMask установлено, то через window.ethereum.request получаем все аккаунты и первый ([0]) записываем, как адрес пользователя.

С помощью web3.eth.getBalance(accounts[0]) получаем баланс аккаунта пользователя и также записываем в состояние баланса (перед этим переводим из wei в ether) и берем только первые 7 символов.

Ниже создаем экземпляр контракта и также записываем в состояние.

const contractInstance = new web3.eth.Contract(abi, contract_address);
setContract(contractInstance);

Оборачиваем все в try-catch для надежности и вызываем функцию внутри UseEffect, тем самым функция будет выполняться при монтировании компонента.

Hidden text

useEffect(() => {
    const initializeContract = async () => {
      const web3 = new Web3(Web3.givenProvider || 'http://127.0.0.1:7545');
      if (window.ethereum) {
        try {
          const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
          if (accounts && accounts.length > 0) {
            setAccounts(accounts);
            setAccount(accounts[0]);
  
            const weiBalance = await web3.eth.getBalance(accounts[0]);
            const etherBalance = web3.utils.fromWei(weiBalance, 'ether');
            setBalance(etherBalance.slice(0, 8));

            const contractInstance = new web3.eth.Contract(abi, contract_address);
            setContract(contractInstance);
          } else {
            console.error('No accounts found');
          }
        } catch (error) {
          console.error('Error fetching account balance:', error);
        }
      } else {
        alert('Install MetaMask extension!');
      }
    };

    initializeContract();
  }, []);

Возвращать будем дочерние компоненты обернутые в ContractContext.Provider и наши стейты с аккаунтами, балансом и т.д.

return (
    
      {children}
    
);

Полный код контекста

Hidden text

// ContractContext.js
import React, { createContext, useContext, useState, useEffect } from 'react';
import Web3 from 'web3';
import { contract_address } from '../config/contract';
import abi from '../config/abi.json';

const ContractContext = createContext();

export const ContractProvider = ({ children }) => {
  const [contract, setContract] = useState(null);
  const [account, setAccount] = useState(null);
  const [accounts, setAccounts] = useState([]);
  const [balance, setBalance] = useState();

  useEffect(() => {
    const initializeContract = async () => {
      const web3 = new Web3(Web3.givenProvider || 'http://127.0.0.1:7545');
      if (window.ethereum) {
        try {
          const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
          if (accounts && accounts.length > 0) {
            setAccounts(accounts);
            setAccount(accounts[0]);
  
            const weiBalance = await web3.eth.getBalance(accounts[0]);
            const etherBalance = web3.utils.fromWei(weiBalance, 'ether');
            setBalance(etherBalance.slice(0, 8));

            const contractInstance = new web3.eth.Contract(abi, contract_address);
            setContract(contractInstance);
          } else {
            console.error('No accounts found');
          }
        } catch (error) {
          console.error('Error fetching account balance:', error);
        }
      } else {
        alert('Install MetaMask extension!');
      }
    };

    initializeContract();
  }, []);

  return (
    
      {children}
    
  );
};

export const useContract = () => {
  const context = useContext(ContractContext);
  if (!context) {
    throw new Error('useContract must be used within a ContractProvider');
  }
  return context;
};

Не забываем обернуть router и app в наш ContractProvider.

e982fc618b7a1c77979b16e4508b2625.png

Верстка страниц и создание компонентов

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

В первую очередь создадим свой header, т.к. функционал сильно ограничен, давим на него название приложения и иконку профиля для перехода на другую страницу.

Создаем папку components для наших компонентов и папку с константами, в нее мы добавляем главный цвет приложения.

6c479adc4141c8461299df7fb37127ec.png

В CustomHeader просто добавляет заголовок для названия и картинку для кнопки перехода на страницу профиля. Для перехода используем Link из react-router-dom.

export default function CustomHeader() {
  return (
    

Twitter dApp

user
) }

Также создадим папку pages и в ней 4 страницы: главная, профиль, профиль другого пользователя и страницу »404». Сейчас в них только div с названием страницы

1fe3cd327701a85acd9f804c543b71d1.png

Переходим в файл App.js и импортируем наш CustomHeader. Внутри функции добавляем наш CustomHeader и routes. В качестве элементов страниц передаем наши страницы из папки pages.

У страницы »404» в качестве пути указываем »*». Теперь если пользователь поменяет путь на несуществующий (любой кроме тех, что мы объявили), ему выдаст страницу с ошибкой.

У страницы другого пользователя добавляем к пути динамический параметр »: userAdr» чтобы в параметрах запроса передавать адрес пользователя и получать его на странице.

function App() {
  return (
    
} /> } /> } /> } />
); }

На странице с ошибкой добавляем базовую надпись и кнопку возвращающую на главную страницу (»/»).

return (
    

Oops...
Page not found

Go home
)

Запускаем Ganache и клиент:

cd client
npm run start

После этого видим наш header и открытое окно MetaMask с подключением аккаунта к приложению. Выбираем первый аккаунт и подключаем.

1c2aa0f62a4b7594e5feed79078f7332.png

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

f75644f0412ac56d889f1ef1cb09a9fa.png

В папке components создаем файл Twitt.jsx, в качестве аргумента, он будет принимать сам твитт, который будет передаваться на странице вывода твиттов.

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

const { account } = useContract();
const isOwner = account == twit.author.login.toLowerCase();

const date = new Date(Number(twit.createdTime) * 1000);
const formattedDateTime = date.toLocaleString();

Далее просто выводим в div блоки поля из переданного твитта и заворачиваем аватарку автора и никнейм в Link, чтобы при нажатии делать переход на страницу пользователя.

Если авторизованный пользователь является автором твитта, то переход будет не на страницу автора, а в профиль. При переходе на страницу автора передаем в параметрах его адрес, который хранится в twitt.

P.S: Правильно будет сделать одну страницу профиля и разграничить логику и дизайн для владельца и гостя. Но в рамках данного приложения мы разделили страницу на две.

import React from 'react'
import theme from '../constants/colors'
import './Twitt.css';
import { Link } from 'react-router-dom';
import { useContract } from '../contexts/ContractContext';

export default function Twitt({twit}) {
    const { account } = useContract();
    const isOwner = account == twit.author.login.toLowerCase();
    
    const date = new Date(Number(twit.createdTime) * 1000);
    const formattedDateTime = date.toLocaleString();

    return (
        
Profile Picture

@{twit.author.username}

{formattedDateTime}

{twit.text}

{twit.likes.toString()}

) }

Переходим к странице профиля, в ней сразу объявляем несколько стейтов. Для удобства я не стал выносить формы входа и обновление аватарки в отдельные компоненты или модальные окна, поэтому все будет хранить внутри одной страницы.

Здесь мы импортируем из контракта баланс и адрес пользователя, чтобы отобразить его в профиле, а также сам контракт для работы с ним.

Также объявляем состояние для текстовых полей регистрации, входа и текста твитта и несколько boolean состояний. isOpened нужен для отслеживания нажатия кнопки включения изменения аватарки пользователя (при значении true будет отображаться поле для ввода ссылки на картинку).

const { contract, balance, account } = useContract();
const [user, setUser] = useState();
const [link, setLink] = useState();

const [text, setText] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');

const [isOpened, setIsOpened] = useState(false);
const [isLogged, setIsLogged] = useState(false);
const [isRegistered, setIsRegistered] = useState(false);

const [twitts, setTwitts] = useState([]);

Далее объявляем useEffect, который будет обновляться при изменении одного из трех стейтов (контракт, регистрация или вход). Если контракт инициализирован, то получает состояние авторизации из localStorage, также вызываем функцию проверяющую зарегистрирован пользователь или нет. Если пользователь авторизован, то получаем его данные и твитты. Сами функции будут описаны ниже.

useEffect(() => {
  if (contract) {
    const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
    setIsLogged(isLoggedIn);

    CheckRegistration();
    
    if(isLogged) {
      GetUserData();
      getUserTwitts();
    }
  }
}, [isLogged, contract, isRegistered]);

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

Также с помощью estimateGas узнаем стоимость транзакции и передаем ее вторым аргументом. В Ganache также можно указать 0-ую цену транзакций и не указывать газ при вызове функций. Все функции будут вызываться с адреса пользователя, поэтому указываем from: account.

const sendTransaction = async (method, args) => {
  try {
    const gasEstimation = await method(...args).estimateGas({ from: account });
    await method(...args).send({ from: account, gas: gasEstimation });
  } catch (err) {
    alert(err);
  }
};

Теперь создаем функции регистрации, авторизации и написания твитта в которых проверяем, что переданные аргументы не пустые и передаем их в функцию sendTransaction, в качестве method, передаем название функции в самом смарт-контракте.

Hidden text

const AddTwitt = async(_text) => {
  if(!_text) return;

  try {
    await sendTransaction(contract.methods.AddTwitt, [_text]);

    setText('');
    getUserTwitts();
  } catch (err) {
    alert(err);
  }
}

const Registration = async(_username, _password) => {
  if(!_username || !_password) return;
  try {
    await sendTransaction(contract.methods.Registration, [_username, _password]);

    setIsRegistered(true);
  } catch (err) {
    alert(err);
  }
}

const Login = async(_password) => {
  if(!_password) return;
  try {
    await sendTransaction(contract.methods.Login, [_password]);

    localStorage.setItem('isLoggedIn', true);
    setIsLogged(true);
  } catch (err) {
    alert(err);
  }
}

В функция с получение данных, мы обращаемся к функция контракта через call, и не платим за их вызов, поэтому не передаем стоимость газа. В качестве аргумента передаем адрес пользователя, т.к. получаем его данные.

const getUserTwitts = async() => {
  try {
    const twitts = await contract.methods.UserTwitts(account).call({ from: account });

    setTwitts(twitts);
  } catch (err) {
    alert(err);
  }
}

const GetUserData = async() => {
  try {
    const user = await contract.methods.GetUser(account).call({ from: account });

    setUser(user);
  } catch (err) {
    alert(err);
  }
}

Далее обычная верстка страницы, рассмотрим основные моменты.

Во первых добавляем условие, что если пользователь не вошел в аккаунт и не зарегистрирован, то выводим ему поля для регистрации, и при успешной регистрации или если пользователь уже имеет аккаунт, у него будет только поле для ввода пароля (ведь логином является адрес аккаунта).
Если пользователь уже в аккаунте, то отображаем кнопку выхода.

{ isLogged ? () : ( isRegistered ? (
setPassword(e.target.value)}/>
) : (
setUsername(e.target.value)}/> setPassword(e.target.value)}/>
) ) }

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

{user &&

Username:
@{user.username}

} {isOpened && setLink(e.target.value)}/>} {isOpened ? : }

Для вывода всех твиттов через map проходим по массиву твиттов и передаем каждый твитт в наш компонент Twitt, в качестве ключа передаем дату создания твитта, также сортируем их по времени, чтобы сначала отображались новые.

Лучше

{
  twitts.sort((a, b) => Number(b.createdTime) - Number(a.createdTime)).map((twit) => {
    return (
      
    );
  })
}

Полный код страницы профиля

Hidden text

import React from 'react'
import '../App.css';
import { useEffect, useState } from 'react';
import './Profile.css';
import theme from '../constants/colors';
import { useContract } from '../contexts/ContractContext';
import Twitt from '../components/Twitt';

export default function Profile() {
    const { contract, balance, account } = useContract();
    const [user, setUser] = useState();
    const [link, setLink] = useState();

    const [text, setText] = useState('');
    const [username, setUsername] = useState('');
    const [password, setPassword] = useState('');

    const [isOpened, setIsOpened] = useState(false);
    const [isLogged, setIsLogged] = useState(false);
    const [isRegistered, setIsRegistered] = useState(false);
    
    const [twitts, setTwitts] = useState([]);

    useEffect(() => {
      if (contract) {
        const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
        setIsLogged(isLoggedIn);

        CheckRegistration();
        
        if(isLogged) {
          GetUserData();
          getUserTwitts();
        }
      }
    }, [isLogged, contract, isRegistered]);

    const sendTransaction = async (method, args) => {
      try {
        const gasEstimation = await method(...args).estimateGas({ from: account });
        await method(...args).send({ from: account, gas: gasEstimation });
      } catch (err) {
        alert(err);
      }
    };

    const AddTwitt = async(_text) => {
      if(!_text) return;

      try {
        await sendTransaction(contract.methods.AddTwitt, [_text]);

        setText('');
        getUserTwitts();
      } catch (err) {
        alert(err);
      }
    }

    const Registration = async(_username, _password) => {
      if(!_username || !_password) return;
      try {
        await sendTransaction(contract.methods.Registration, [_username, _password]);

        setIsRegistered(true);
      } catch (err) {
        alert(err);
      }
    }

    const Login = async(_password) => {
      if(!_password) return;
      try {
        await sendTransaction(contract.methods.Login, [_password]);

        localStorage.setItem('isLoggedIn', true);
        setIsLogged(true);
      } catch (err) {
        alert(err);
      }
    }

    const getUserTwitts = async() => {
      try {
        const twitts = await contract.methods.UserTwitts(account).call({ from: account });

        setTwitts(twitts);
      } catch (err) {
        alert(err);
      }
    }

    const GetUserData = async() => {
      try {
        const user = await contract.methods.GetUser(account).call({ from: account });

        setUser(user);
      } catch (err) {
        alert(err);
      }
    }

    const UpdateAvatar = async(link) => {
      if(!link) return;
      try {
        await sendTransaction(contract.methods.UpdateUser, [link]);

        setLink('');
        setIsOpened(false);
        GetUserData();
      } catch (err) {
        alert(err);
      }
    }

    const Exit = async() => {
      try {
        await sendTransaction(contract.methods.Logout, []);

        localStorage.setItem('isLoggedIn', false);
        setIsLogged(false);
        setTwitts([]);
      } catch (err) {
        alert(err);
      }
    }

    const CheckRegistration = async() => {
      try {
        const result = await contract.methods.CheckRegistration(account).call({ from: account });
        setIsRegistered(result);
      } catch (err) {
        alert(err);
      }
    }

    return (
      
avatar

Account: {account}

Balance: {balance} BEBRA

{ isLogged ? () : ( isRegistered ? (
setPassword(e.target.value)}/>
) : (
setUsername(e.target.value)}/> setPassword(e.target.value)}/>
) ) }
{isLogged &&
{user &&

Username:
@{user.username}

} {isOpened && setLink(e.target.value)}/>} {isOpened ? : }
}
{isLogged &&