Создание полностекового web3-приложения с помощью Next.js, Polygon, Solidity, The Graph, IPFS и Hardhat
Видео к этому учебнику также доступно здесь
В этом подробном уроке вы узнаете об инструментах, протоколах и фреймворках для создания полностековых web3-приложений, а главное — как собрать все вместе, чтобы заложить основу для реализации ваших собственных идей в будущем.
Кодовая база для этого приложения находится здесь
Основная сеть, в которой мы будем разворачивать приложение, — Polygon. Я выбрал Polygon из-за ее низкой стоимости транзакций, быстрого времени блокировки и текущего внедрения сети.
Тем не менее, мы будем строить на виртуальной машине Ethereum (EVM), поэтому вы можете применить эти навыки для создания приложений для десятков других сетей блокчейн, включая Ethereum, Celo, Avalanche и многие другие.
Приложение, которое мы будем создавать, представляет собой блог с полным стеком, а также систему управления контентом (CMS), что означает, что у вас будет открытый, публичный и композитный бэк-энд, который можно переносить и повторно использовать где угодно.
К концу этого руководства у вас должно быть хорошее понимание наиболее важных частей современного стека web3 и того, как создавать производительные, масштабируемые, децентрализованные блокчейн-приложения с полным стеком.
Это четвертое руководство в моей серии «Полный стек» web3. Также обязательно ознакомьтесь с:
- Полное руководство по разработке полного стека Ethereum
- Создание полнофункционального рынка NFT на Ethereum с помощью Polygon
- Полное руководство по разработке полного стека Solana с использованием React, Anchor, Rust и Phantom.
Стек web3
В статье «Определение стека web3» я написал о своей интерпретации текущего состояния технологического стека web3 с точки зрения разработчика, используя сочетание моего личного опыта и исследований, проведенных за последний год моей командой в Edge & Node.
Мы будем использовать части этого стека для данного приложения:
- Блокчейн — Polygon (с дополнительным провайдером RPC)
- Среда разработки Ethereum — Hardhat
- Front end framework — Next.js & React
- Библиотека веб-клиента Ethereum — Ethers.js
- Файловое хранилище — IPFS
- Индексирование и запросы — протокол Graph Protocol
Научившись использовать эти строительные блоки, мы сможем создавать множество типов приложений, поэтому цель этого урока — показать, как они работают и как сочетаются друг с другом.
Давайте приступим!
Необходимые условия
- Node.js установлен на вашей локальной машине
- Расширение MetaMask Chrome, установленное в вашем браузере
Настройка проекта
Здесь мы создадим шаблоны приложений, установим все необходимые зависимости и настроим проект.
Код будет прокомментирован, чтобы вы понимали, что происходит, и я также буду описывать некоторые из происходящих событий на протяжении всего урока.
Чтобы начать, создайте новое приложение Next.js и перейдите в новую директорию:
npx create-next-app web3-blog
cd web3-blog
Затем перейдите в новый каталог и установите следующие зависимости с помощью npm, yarn или pnpm:
npm install ethers hardhat @nomiclabs/hardhat-waffle
ethereum-waffle chai @nomiclabs/hardhat-ethers
web3modal @walletconnect/web3-provider
easymde react-markdown react-simplemde-editor
ipfs-http-client @emotion/css @openzeppelin/contracts
Обзор некоторых из этих зависимостей:
hardhat — среда разработки Ethereum
web3modal — простая в использовании библиотека, которая позволяет пользователям подключать свои кошельки к вашему приложению
react-markdown и simplemde — редактор уценки и рендерер уценки для CMS
@emotion/css — Отличная библиотека CSS в JS
@openzeppelin/contracts — Реализация полезных стандартов и функциональности смарт-контрактов с открытым исходным кодом.
Далее мы инициализируем локальную среду разработки смарт-контрактов.
npx hardhat
? What do you want to do? Create a basic sample project
? Hardhat project root: <Choose default path>
Если вы получите ошибку со ссылкой на ваш README.md, удалите README.md и запустите
npx hardhat
снова.
Это должно создать базовую среду разработки Solidity, которую мы будем использовать. Вы должны увидеть несколько новых файлов и папок, включая contracts, scripts, test и hardhat.config.js.
Далее, давайте обновим конфигурацию hardhat в файле hardhat.config.js.
Обновите этот файл следующим кодом:
require("@nomiclabs/hardhat-waffle");
module.exports = {
solidity: "0.8.4",
networks: {
hardhat: {
chainId: 1337
},
// mumbai: {
// url: "https://rpc-mumbai.matic.today",
// accounts: [process.env.pk]
// },
// polygon: {
// url: "https://polygon-rpc.com/",
// accounts: [process.env.pk]
// }
}
};
Здесь мы настроили локальную среду разработки hardhat, а также настроили (и закомментировали) среды Polygon mainnet и Mumbai testnet, которые мы будем использовать для развертывания в Polygon.
Далее, давайте добавим несколько основных глобальных CSS, которые нам понадобятся для оформления редактора разметки для CMS.
Откройте файл styles/globals.css и добавьте следующий код под существующим css:
.EasyMDEContainer .editor-toolbar {
border: none;
}
.EasyMDEContainer .CodeMirror {
border: none !important;
background: none;
}
.editor-preview {
background-color: white !important;
}
.CodeMirror .cm-spell-error:not(.cm-url):not(.cm-comment):not(.cm-tag):not(.cm-word) {
background-color: transparent !important;
}
pre {
padding: 20px;
background-color: #efefef;
}
blockquote {
border-left: 5px solid #ddd;
padding-left: 20px;
margin-left: 0px;
}
Далее мы создадим пару SVG-файлов для изображений приложения, один для логотипа и один для кнопки со стрелкой.
Создайте logo.svg и right-arrow.svg в общей папке и скопируйте связанный SVG-код в каждый из этих файлов.
Смарт-контракт
Далее давайте создадим смарт-контракт, который будет управлять нашим блогом и CMS.
Создайте новый файл в папке contracts с именем Blog.sol.
Добавьте в него следующий код:
// contracts/Blog.sol
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import "hardhat/console.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract Blog {
string public name;
address public owner;
using Counters for Counters.Counter;
Counters.Counter private _postIds;
struct Post {
uint id;
string title;
string content;
bool published;
}
/* mappings can be seen as hash tables */
/* here we create lookups for posts by id and posts by ipfs hash */
mapping(uint => Post) private idToPost;
mapping(string => Post) private hashToPost;
/* events facilitate communication between smart contractsand their user interfaces */
/* i.e. we can create listeners for events in the client and also use them in The Graph */
event PostCreated(uint id, string title, string hash);
event PostUpdated(uint id, string title, string hash, bool published);
/* when the blog is deployed, give it a name */
/* also set the creator as the owner of the contract */
constructor(string memory _name) {
console.log("Deploying Blog with name:", _name);
name = _name;
owner = msg.sender;
}
/* updates the blog name */
function updateName(string memory _name) public {
name = _name;
}
/* transfers ownership of the contract to another address */
function transferOwnership(address newOwner) public onlyOwner {
owner = newOwner;
}
/* fetches an individual post by the content hash */
function fetchPost(string memory hash) public view returns(Post memory){
return hashToPost[hash];
}
/* creates a new post */
function createPost(string memory title, string memory hash) public onlyOwner {
_postIds.increment();
uint postId = _postIds.current();
Post storage post = idToPost[postId];
post.id = postId;
post.title = title;
post.published = true;
post.content = hash;
hashToPost[hash] = post;
emit PostCreated(postId, title, hash);
}
/* updates an existing post */
function updatePost(uint postId, string memory title, string memory hash, bool published) public onlyOwner {
Post storage post = idToPost[postId];
post.title = title;
post.published = published;
post.content = hash;
idToPost[postId] = post;
hashToPost[hash] = post;
emit PostUpdated(post.id, title, hash, published);
}
/* fetches all posts */
function fetchPosts() public view returns (Post[] memory) {
uint itemCount = _postIds.current();
Post[] memory posts = new Post[](itemCount);
for (uint i = 0; i < itemCount; i++) {
uint currentId = i + 1;
Post storage currentItem = idToPost[currentId];
posts[i] = currentItem;
}
return posts;
}
/* this modifier means only the contract owner can */
/* invoke the function */
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
}
Этот контракт позволяет владельцу создавать и редактировать сообщения, а любому человеку — получать сообщения.
Чтобы сделать этот смарт-контракт без прав доступа, можно удалить модификатор
onlyOwner
и использовать The Graph для индексации и запроса постов по владельцу.
Далее давайте напишем базовый тест, чтобы проверить наиболее важную функциональность, которую мы будем использовать.
Для этого откройте файл test/sample-test.js и обновите его следующим кодом:
const { expect } = require("chai")
const { ethers } = require("hardhat")
describe("Blog", async function () {
it("Should create a post", async function () {
const Blog = await ethers.getContractFactory("Blog")
const blog = await Blog.deploy("My blog")
await blog.deployed()
await blog.createPost("My first post", "12345")
const posts = await blog.fetchPosts()
expect(posts[0].title).to.equal("My first post")
})
it("Should edit a post", async function () {
const Blog = await ethers.getContractFactory("Blog")
const blog = await Blog.deploy("My blog")
await blog.deployed()
await blog.createPost("My Second post", "12345")
await blog.updatePost(1, "My updated post", "23456", true)
posts = await blog.fetchPosts()
expect(posts[0].title).to.equal("My updated post")
})
it("Should add update the name", async function () {
const Blog = await ethers.getContractFactory("Blog")
const blog = await Blog.deploy("My blog")
await blog.deployed()
expect(await blog.name()).to.equal("My blog")
await blog.updateName('My new blog')
expect(await blog.name()).to.equal("My new blog")
})
})
Затем запустите тест, открыв терминал и выполнив следующую команду:
npx hardhat test
Развертывание контракта
Теперь, когда контракт написан и протестирован, давайте попробуем развернуть его в локальной тестовой сети.
Чтобы запустить локальную сеть, откройте в терминале как минимум два отдельных окна. В одном окне запустите следующий скрипт:
npx hardhat node
Когда мы выполним эту команду, вы должны увидеть список адресов и закрытых ключей.
Это 20 тестовых аккаунтов и адресов, созданных для нас, которые мы можем использовать для развертывания и тестирования наших смарт-контрактов. Каждый аккаунт также загружен 10 000 фальшивых Эфиров. Через некоторое время мы узнаем, как импортировать тестовый счет в MetaMask, чтобы мы могли его использовать.
Далее нам нужно развернуть контракт в тестовой сети. Сначала измените имя файла scripts/sample-script.js на scripts/deploy.js.
Затем обновите файл с этим новым сценарием развертывания:
/* scripts/deploy.js */
const hre = require("hardhat");
const fs = require('fs');
async function main() {
/* these two lines deploy the contract to the network */
const Blog = await hre.ethers.getContractFactory("Blog");
const blog = await Blog.deploy("My blog");
await blog.deployed();
console.log("Blog deployed to:", blog.address);
/* this code writes the contract addresses to a local */
/* file named config.js that we can use in the app */
fs.writeFileSync('./config.js', `
export const contractAddress = "${blog.address}"
export const ownerAddress = "${blog.signer.address}"
`)
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Теперь в отдельном окне (пока локальная сеть все еще работает) мы можем запустить сценарий развертывания и указать флаг CLI, что мы хотим развернуть в нашей локальной сети:
npx hardhat run scripts/deploy.js --network localhost
Когда контракт будет развернут, вы должны увидеть некоторый вывод в вашем сетевом терминале ?.
Импорт тестового счета в ваш кошелек
Чтобы отправлять транзакции смарт-контракту, нам нужно подключить наш кошелек MetaMask, используя один из аккаунтов, созданных при запуске npx hardhat node
. В списке контрактов, который выводит CLI, вы должны увидеть как номер счета, так и закрытый ключ:
➜ react-dapp git:(main) npx hardhat node
Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/
Accounts
========
Account #0: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH)
Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
...
Мы можем импортировать этот аккаунт в MetaMask, чтобы начать использовать некоторые из доступных там поддельных Eth.
Для этого сначала откройте MetaMask и включите тестовые сети:
Затем измените сеть на Localhost 8545:
Далее, в MetaMask нажмите на Import Account в меню accounts:
Скопируйте и вставьте самый первый закрытый ключ, полученный из CLI, и нажмите Импортировать. Как только учетная запись будет импортирована, вы должны увидеть Eth в учетной записи:
Убедитесь, что вы импортировали первый счет в списке счетов (счет #0), так как этот счет будет использоваться по умолчанию при развертывании контракта и, следовательно, будет владельцем контракта.
Теперь, когда у нас есть развернутый смарт-контракт и готовый к использованию аккаунт, мы можем начать взаимодействовать с ним из приложения Next.js.
Приложение Next.js
Далее давайте напишем код для приложения.
Первое, что мы сделаем, это настроим пару переменных окружения, которые мы будем использовать для переключения между локальной средой тестирования, Mumbai testnet и Polygon mainnet.
Создайте новый файл с именем .env.local в корне вашего проекта и добавьте следующую конфигурацию для начала:
ENVIRONMENT="local"
NEXT_PUBLIC_ENVIRONMENT="local"
Мы сможем переключать эти переменные между local
, testnet
, и mainnet
для переключения между
Это позволит нам ссылаться на наше окружение как на клиенте, так и на сервере. Чтобы узнать больше о том, как работают переменные окружения в Next.js, ознакомьтесь с документацией здесь.
context.js
Далее давайте создадим контекст приложения. Контекст даст нам простой способ обмена состоянием во всем приложении.
Создайте файл с именем context.js и добавьте в него следующий код:
import { createContext } from 'react'
export const AccountContext = createContext(null)
Макет и навигация
Далее откроем файл pages/_app.js. Здесь мы обновим код, чтобы включить навигацию, подключение к кошельку, контекст и некоторые базовые стили.
Эта страница служит в качестве обертки или макета для остальной части приложения.
/* pages/__app.js */
import '../styles/globals.css'
import { useState } from 'react'
import Link from 'next/link'
import { css } from '@emotion/css'
import { ethers } from 'ethers'
import Web3Modal from 'web3modal'
import WalletConnectProvider from '@walletconnect/web3-provider'
import { AccountContext } from '../context.js'
import { ownerAddress } from '../config'
import 'easymde/dist/easymde.min.css'
function MyApp({ Component, pageProps }) {
/* create local state to save account information after signin */
const [account, setAccount] = useState(null)
/* web3Modal configuration for enabling wallet access */
async function getWeb3Modal() {
const web3Modal = new Web3Modal({
cacheProvider: false,
providerOptions: {
walletconnect: {
package: WalletConnectProvider,
options: {
infuraId: "your-infura-id"
},
},
},
})
return web3Modal
}
/* the connect function uses web3 modal to connect to the user's wallet */
async function connect() {
try {
const web3Modal = await getWeb3Modal()
const connection = await web3Modal.connect()
const provider = new ethers.providers.Web3Provider(connection)
const accounts = await provider.listAccounts()
setAccount(accounts[0])
} catch (err) {
console.log('error:', err)
}
}
return (
<div>
<nav className={nav}>
<div className={header}>
<Link href="/">
<a>
<img
src='/logo.svg'
alt="React Logo"
style={{ width: '50px' }}
/>
</a>
</Link>
<Link href="/">
<a>
<div className={titleContainer}>
<h2 className={title}>Full Stack</h2>
<p className={description}>WEB3</p>
</div>
</a>
</Link>
{
!account && (
<div className={buttonContainer}>
<button className={buttonStyle} onClick={connect}>Connect</button>
</div>
)
}
{
account && <p className={accountInfo}>{account}</p>
}
</div>
<div className={linkContainer}>
<Link href="/" >
<a className={link}>
Home
</a>
</Link>
{
/* if the signed in user is the contract owner, we */
/* show the nav link to create a new post */
(account === ownerAddress) && (
<Link href="/create-post">
<a className={link}>
Create Post
</a>
</Link>
)
}
</div>
</nav>
<div className={container}>
<AccountContext.Provider value={account}>
<Component {...pageProps} connect={connect} />
</AccountContext.Provider>
</div>
</div>
)
}
const accountInfo = css`
width: 100%;
display: flex;
flex: 1;
justify-content: flex-end;
font-size: 12px;
`
const container = css`
padding: 40px;
`
const linkContainer = css`
padding: 30px 60px;
background-color: #fafafa;
`
const nav = css`
background-color: white;
`
const header = css`
display: flex;
border-bottom: 1px solid rgba(0, 0, 0, .075);
padding: 20px 30px;
`
const description = css`
margin: 0;
color: #999999;
`
const titleContainer = css`
display: flex;
flex-direction: column;
padding-left: 15px;
`
const title = css`
margin-left: 30px;
font-weight: 500;
margin: 0;
`
const buttonContainer = css`
width: 100%;
display: flex;
flex: 1;
justify-content: flex-end;
`
const buttonStyle = css`
background-color: #fafafa;
outline: none;
border: none;
font-size: 18px;
padding: 16px 70px;
border-radius: 15px;
cursor: pointer;
box-shadow: 7px 7px rgba(0, 0, 0, .1);
`
const link = css`
margin: 0px 40px 0px 0px;
font-size: 16px;
font-weight: 400;
`
export default MyApp
Точка входа
Теперь, когда макет настроен, давайте создадим точку входа в приложение.
Эта страница будет получать список постов из сети и отображать заголовки постов в виде списка. Когда пользователь нажмет на пост, мы переведем его на другую страницу для детального просмотра (страница будет создана позже).
/* pages/index.js */
import { css } from '@emotion/css'
import { useContext } from 'react'
import { useRouter } from 'next/router'
import { ethers } from 'ethers'
import Link from 'next/link'
import { AccountContext } from '../context'
/* import contract address and contract owner address */
import {
contractAddress, ownerAddress
} from '../config'
/* import Application Binary Interface (ABI) */
import Blog from '../artifacts/contracts/Blog.sol/Blog.json'
export default function Home(props) {
/* posts are fetched server side and passed in as props */
/* see getServerSideProps */
const { posts } = props
const account = useContext(AccountContext)
const router = useRouter()
async function navigate() {
router.push('/create-post')
}
return (
<div>
<div className={postList}>
{
/* map over the posts array and render a button with the post title */
posts.map((post, index) => (
<Link href={`/post/${post[2]}`} key={index}>
<a>
<div className={linkStyle}>
<p className={postTitle}>{post[1]}</p>
<div className={arrowContainer}>
<img
src='/right-arrow.svg'
alt='Right arrow'
className={smallArrow}
/>
</div>
</div>
</a>
</Link>
))
}
</div>
<div className={container}>
{
(account === ownerAddress) && posts && !posts.length && (
/* if the signed in user is the account owner, render a button */
/* to create the first post */
<button className={buttonStyle} onClick={navigate}>
Create your first post
<img
src='/right-arrow.svg'
alt='Right arrow'
className={arrow}
/>
</button>
)
}
</div>
</div>
)
}
export async function getServerSideProps() {
/* here we check to see the current environment variable */
/* and render a provider based on the environment we're in */
let provider
if (process.env.ENVIRONMENT === 'local') {
provider = new ethers.providers.JsonRpcProvider()
} else if (process.env.ENVIRONMENT === 'testnet') {
provider = new ethers.providers.JsonRpcProvider('https://rpc-mumbai.matic.today')
} else {
provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com/')
}
const contract = new ethers.Contract(contractAddress, Blog.abi, provider)
const data = await contract.fetchPosts()
return {
props: {
posts: JSON.parse(JSON.stringify(data))
}
}
}
const arrowContainer = css`
display: flex;
flex: 1;
justify-content: flex-end;
padding-right: 20px;
`
const postTitle = css`
font-size: 30px;
font-weight: bold;
cursor: pointer;
margin: 0;
padding: 20px;
`
const linkStyle = css`
border: 1px solid #ddd;
margin-top: 20px;
border-radius: 8px;
display: flex;
`
const postList = css`
width: 700px;
margin: 0 auto;
padding-top: 50px;
`
const container = css`
display: flex;
justify-content: center;
`
const buttonStyle = css`
margin-top: 100px;
background-color: #fafafa;
outline: none;
border: none;
font-size: 44px;
padding: 20px 70px;
border-radius: 15px;
cursor: pointer;
box-shadow: 7px 7px rgba(0, 0, 0, .1);
`
const arrow = css`
width: 35px;
margin-left: 30px;
`
const smallArrow = css`
width: 25px;
`
Создание постов
Далее создайте новый файл в каталоге pages с именем create-post.js.
Он будет содержать маршрут, который позволит нам создавать посты и сохранять их в сети.
У нас также будет возможность загружать и сохранять изображение обложки в IPFS, при этом хэш ipfs-загрузки будет закреплен по цепочке с остальными данными.
Добавьте следующий код в этот файл:
/* pages/create-post.js */
import { useState, useRef, useEffect } from 'react' // new
import { useRouter } from 'next/router'
import dynamic from 'next/dynamic'
import { css } from '@emotion/css'
import { ethers } from 'ethers'
import { create } from 'ipfs-http-client'
/* import contract address and contract owner address */
import {
contractAddress
} from '../config'
import Blog from '../artifacts/contracts/Blog.sol/Blog.json'
/* define the ipfs endpoint */
const client = create('https://ipfs.infura.io:5001/api/v0')
/* configure the markdown editor to be client-side import */
const SimpleMDE = dynamic(
() => import('react-simplemde-editor'),
{ ssr: false }
)
const initialState = { title: '', content: '' }
function CreatePost() {
/* configure initial state to be used in the component */
const [post, setPost] = useState(initialState)
const [image, setImage] = useState(null)
const [loaded, setLoaded] = useState(false)
const fileRef = useRef(null)
const { title, content } = post
const router = useRouter()
useEffect(() => {
setTimeout(() => {
/* delay rendering buttons until dynamic import is complete */
setLoaded(true)
}, 500)
}, [])
function onChange(e) {
setPost(() => ({ ...post, [e.target.name]: e.target.value }))
}
async function createNewPost() {
/* saves post to ipfs then anchors to smart contract */
if (!title || !content) return
const hash = await savePostToIpfs()
await savePost(hash)
router.push(`/`)
}
async function savePostToIpfs() {
/* save post metadata to ipfs */
try {
const added = await client.add(JSON.stringify(post))
return added.path
} catch (err) {
console.log('error: ', err)
}
}
async function savePost(hash) {
/* anchor post to smart contract */
if (typeof window.ethereum !== 'undefined') {
const provider = new ethers.providers.Web3Provider(window.ethereum)
const signer = provider.getSigner()
const contract = new ethers.Contract(contractAddress, Blog.abi, signer)
console.log('contract: ', contract)
try {
const val = await contract.createPost(post.title, hash)
/* optional - wait for transaction to be confirmed before rerouting */
/* await provider.waitForTransaction(val.hash) */
console.log('val: ', val)
} catch (err) {
console.log('Error: ', err)
}
}
}
function triggerOnChange() {
/* trigger handleFileChange handler of hidden file input */
fileRef.current.click()
}
async function handleFileChange (e) {
/* upload cover image to ipfs and save hash to state */
const uploadedFile = e.target.files[0]
if (!uploadedFile) return
const added = await client.add(uploadedFile)
setPost(state => ({ ...state, coverImage: added.path }))
setImage(uploadedFile)
}
return (
<div className={container}>
{
image && (
<img className={coverImageStyle} src={URL.createObjectURL(image)} />
)
}
<input
onChange={onChange}
name='title'
placeholder='Give it a title ...'
value={post.title}
className={titleStyle}
/>
<SimpleMDE
className={mdEditor}
placeholder="What's on your mind?"
value={post.content}
onChange={value => setPost({ ...post, content: value })}
/>
{
loaded && (
<>
<button
className={button}
type='button'
onClick={createNewPost}
>Publish</button>
<button
onClick={triggerOnChange}
className={button}
>Add cover image</button>
</>
)
}
<input
id='selectImage'
className={hiddenInput}
type='file'
onChange={handleFileChange}
ref={fileRef}
/>
</div>
)
}
const hiddenInput = css`
display: none;
`
const coverImageStyle = css`
max-width: 800px;
`
const mdEditor = css`
margin-top: 40px;
`
const titleStyle = css`
margin-top: 40px;
border: none;
outline: none;
background-color: inherit;
font-size: 44px;
font-weight: 600;
&::placeholder {
color: #999999;
}
`
const container = css`
width: 800px;
margin: 0 auto;
`
const button = css`
background-color: #fafafa;
outline: none;
border: none;
border-radius: 15px;
cursor: pointer;
margin-right: 10px;
font-size: 18px;
padding: 16px 70px;
box-shadow: 7px 7px rgba(0, 0, 0, .1);
`
export default CreatePost
Просмотр поста
Теперь, когда у нас есть возможность создавать посты, как нам перемещаться и просматривать посты? Мы хотим иметь возможность просматривать пост в маршруте, который выглядит примерно так myapp.com/post/some-post-id
.
Мы можем сделать это несколькими различными способами с помощью динамических маршрутов next.js.
Мы будем использовать серверную выборку данных с помощью getStaticPaths и getStaticProps, которые создадут эти страницы во время сборки, используя массив постов, запрашиваемых из сети.
Для этого создайте новую папку в каталоге pages с именем post и файл в этой папке с именем [id].js. Добавьте туда следующий код:
/* pages/post/[id].js */
import ReactMarkdown from 'react-markdown'
import { useContext } from 'react'
import { useRouter } from 'next/router'
import Link from 'next/link'
import { css } from '@emotion/css'
import { ethers } from 'ethers'
import { AccountContext } from '../../context'
/* import contract and owner addresses */
import {
contractAddress, ownerAddress
} from '../../config'
import Blog from '../../artifacts/contracts/Blog.sol/Blog.json'
const ipfsURI = 'https://ipfs.io/ipfs/'
export default function Post({ post }) {
const account = useContext(AccountContext)
const router = useRouter()
const { id } = router.query
if (router.isFallback) {
return <div>Loading...</div>
}
return (
<div>
{
post && (
<div className={container}>
{
/* if the owner is the user, render an edit button */
ownerAddress === account && (
<div className={editPost}>
<Link href={`/edit-post/${id}`}>
<a>
Edit post
</a>
</Link>
</div>
)
}
{
/* if the post has a cover image, render it */
post.coverImage && (
<img
src={post.coverImage}
className={coverImageStyle}
/>
)
}
<h1>{post.title}</h1>
<div className={contentContainer}>
<ReactMarkdown>{post.content}</ReactMarkdown>
</div>
</div>
)
}
</div>
)
}
export async function getStaticPaths() {
/* here we fetch the posts from the network */
let provider
if (process.env.ENVIRONMENT === 'local') {
provider = new ethers.providers.JsonRpcProvider()
} else if (process.env.ENVIRONMENT === 'testnet') {
provider = new ethers.providers.JsonRpcProvider('https://rpc-mumbai.matic.today')
} else {
provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com/')
}
const contract = new ethers.Contract(contractAddress, Blog.abi, provider)
const data = await contract.fetchPosts()
/* then we map over the posts and create a params object passing */
/* the id property to getStaticProps which will run for ever post */
/* in the array and generate a new page */
const paths = data.map(d => ({ params: { id: d[2] } }))
return {
paths,
fallback: true
}
}
export async function getStaticProps({ params }) {
/* using the id property passed in through the params object */
/* we can us it to fetch the data from IPFS and pass the */
/* post data into the page as props */
const { id } = params
const ipfsUrl = `${ipfsURI}/${id}`
const response = await fetch(ipfsUrl)
const data = await response.json()
if(data.coverImage) {
let coverImage = `${ipfsURI}/${data.coverImage}`
data.coverImage = coverImage
}
return {
props: {
post: data
},
}
}
const editPost = css`
margin: 20px 0px;
`
const coverImageStyle = css`
width: 900px;
`
const container = css`
width: 900px;
margin: 0 auto;
`
const contentContainer = css`
margin-top: 60px;
padding: 0px 40px;
border-left: 1px solid #e7e7e7;
border-right: 1px solid #e7e7e7;
& img {
max-width: 900px;
}
`
Редактирование поста
Последняя страница, которую нам нужно создать, — это способ редактирования существующих постов.
Эта страница перенесет некоторые функции из pages/create-post.js, а также pages/post/[id].js. Мы сможем переключаться между просмотром и редактированием сообщения.
Создайте новую папку в каталоге pages под названием edit-post и файл [id].js в ней. Затем добавьте следующий код:
/* pages/edit-post/[id].js */
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'
import ReactMarkdown from 'react-markdown'
import { css } from '@emotion/css'
import dynamic from 'next/dynamic'
import { ethers } from 'ethers'
import { create } from 'ipfs-http-client'
import {
contractAddress
} from '../../config'
import Blog from '../../artifacts/contracts/Blog.sol/Blog.json'
const ipfsURI = 'https://ipfs.io/ipfs/'
const client = create('https://ipfs.infura.io:5001/api/v0')
const SimpleMDE = dynamic(
() => import('react-simplemde-editor'),
{ ssr: false }
)
export default function Post() {
const [post, setPost] = useState(null)
const [editing, setEditing] = useState(true)
const router = useRouter()
const { id } = router.query
useEffect(() => {
fetchPost()
}, [id])
async function fetchPost() {
/* we first fetch the individual post by ipfs hash from the network */
if (!id) return
let provider
if (process.env.NEXT_PUBLIC_ENVIRONMENT === 'local') {
provider = new ethers.providers.JsonRpcProvider()
} else if (process.env.NEXT_PUBLIC_ENVIRONMENT === 'testnet') {
provider = new ethers.providers.JsonRpcProvider('https://rpc-mumbai.matic.today')
} else {
provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com/')
}
const contract = new ethers.Contract(contractAddress, Blog.abi, provider)
const val = await contract.fetchPost(id)
const postId = val[0].toNumber()
/* next we fetch the IPFS metadata from the network */
const ipfsUrl = `${ipfsURI}/${id}`
const response = await fetch(ipfsUrl)
const data = await response.json()
if(data.coverImage) {
let coverImagePath = `${ipfsURI}/${data.coverImage}`
data.coverImagePath = coverImagePath
}
/* finally we append the post ID to the post data */
/* we need this ID to make updates to the post */
data.id = postId;
setPost(data)
}
async function savePostToIpfs() {
try {
const added = await client.add(JSON.stringify(post))
return added.path
} catch (err) {
console.log('error: ', err)
}
}
async function updatePost() {
const hash = await savePostToIpfs()
const provider = new ethers.providers.Web3Provider(window.ethereum)
const signer = provider.getSigner()
const contract = new ethers.Contract(contractAddress, Blog.abi, signer)
await contract.updatePost(post.id, post.title, hash, true)
router.push('/')
}
if (!post) return null
return (
<div className={container}>
{
/* editing state will allow the user to toggle between */
/* a markdown editor and a markdown renderer */
}
{
editing && (
<div>
<input
onChange={e => setPost({ ...post, title: e.target.value })}
name='title'
placeholder='Give it a title ...'
value={post.title}
className={titleStyle}
/>
<SimpleMDE
className={mdEditor}
placeholder="What's on your mind?"
value={post.content}
onChange={value => setPost({ ...post, content: value })}
/>
<button className={button} onClick={updatePost}>Update post</button>
</div>
)
}
{
!editing && (
<div>
{
post.coverImagePath && (
<img
src={post.coverImagePath}
className={coverImageStyle}
/>
)
}
<h1>{post.title}</h1>
<div className={contentContainer}>
<ReactMarkdown>{post.content}</ReactMarkdown>
</div>
</div>
)
}
<button className={button} onClick={() => setEditing(editing ? false : true)}>{ editing ? 'View post' : 'Edit post'}</button>
</div>
)
}
const button = css`
background-color: #fafafa;
outline: none;
border: none;
border-radius: 15px;
cursor: pointer;
margin-right: 10px;
margin-top: 15px;
font-size: 18px;
padding: 16px 70px;
box-shadow: 7px 7px rgba(0, 0, 0, .1);
`
const titleStyle = css`
margin-top: 40px;
border: none;
outline: none;
background-color: inherit;
font-size: 44px;
font-weight: 600;
&::placeholder {
color: #999999;
}
`
const mdEditor = css`
margin-top: 40px;
`
const coverImageStyle = css`
width: 900px;
`
const container = css`
width: 900px;
margin: 0 auto;
`
const contentContainer = css`
margin-top: 60px;
padding: 0px 40px;
border-left: 1px solid #e7e7e7;
border-right: 1px solid #e7e7e7;
& img {
max-width: 900px;
}
`
Тестирование
Теперь мы можем протестировать его.
Для этого убедитесь, что вы уже развернули контракт в сеть в предыдущих шагах и у вас все еще работает локальная сеть.
Откройте новое окно терминала и запустите приложение Next.js:
npm run dev
Когда приложение запустится, вы сможете подключить свой кошелек и взаимодействовать с приложением.
Вы также должны иметь возможность создать новый пост.
Вы можете заметить, что приложение работает не так быстро, как могло бы быть, но Next.js работает молниеносно в продакшене.
Чтобы запустить производственную сборку, выполните следующие команды:
npm run build && npm start
Развертывание на Polygon
Теперь, когда проект запущен и протестирован локально, давайте развернем его в Polygon. Мы начнем с развертывания в Мумбаи, тестовой сети Polygon.
Первое, что нам нужно будет сделать, это установить один из наших закрытых ключей из кошелька в качестве переменной окружения.
Чтобы получить приватный ключ, вы можете экспортировать его прямо из MetaMask.
Приватные ключи никогда и ни при каких обстоятельствах не предназначены для публичного обмена. Рекомендуется никогда не записывать закрытый ключ в файл. Если вы все же решили это сделать, убедитесь, что используете тестовый кошелек и ни при каких обстоятельствах не передавайте файл, содержащий закрытый ключ, в систему контроля исходных кодов и не выкладывайте его в открытый доступ.
Если вы работаете на компьютере Mac, вы можете установить переменную окружения из командной строки следующим образом (не забудьте запустить сценарий развертывания из этого же терминала и сессии):
export pk="your-private-key"
Настройка сети
Далее нам нужно переключиться с локальной тестовой сети на Mumbai Testnet.
Для этого нам нужно создать и настроить конфигурацию сети.
Во-первых, откройте MetaMask и нажмите на Настройки.
Затем нажмите на Сети, а затем Добавить сеть:
Здесь мы добавим следующие конфигурации для тестовой сети Мумбаи, перечисленные здесь:
Имя сети: Mumbai TestNet
Новый URL RPC: https://rpc-mumbai.matic.today
Идентификатор сети: 80001
Символ валюты: Matic
Сохраните это, после чего вы сможете переключиться на новую сеть и использовать ее!
Наконец, вам понадобятся токены testnet Polygon, чтобы взаимодействовать с приложениями.
Чтобы получить их, вы можете посетить Polygon Faucet, введя адреса кошельков, с которых вы хотите запросить токены.
Развертывание в сети Polygon
Теперь, когда у вас есть токены, вы можете развернуть их в сети Polygon!
Для этого убедитесь, что адрес, связанный с закрытым ключом, с помощью которого вы развертываете свой контракт, получил токены, чтобы оплатить комиссию за транзакцию.
Затем откомментируйте конфигурацию mumbai
в файле hardhat.config.js:
mumbai: {
url: "https://rpc-mumbai.matic.today",
accounts: [process.env.pk]
},
Для развертывания в тестовой сети Polygon выполните следующую команду:
npx hardhat run scripts/deploy.js --network mumbai
Если вы столкнулись с этой ошибкой:
ProviderError: RPCError
, публичный RPC может быть перегружен. В производстве рекомендуется использовать RPC-провайдер, например Infura, Alchemy или Quicknode.
Далее обновите переменные окружения в .env.local, чтобы они были testnet
:
ENVIRONMENT="testnet"
NEXT_PUBLIC_ENVIRONMENT="testnet"
Затем перезапустите сервер, чтобы зарегистрировать изменения в переменных окружения:
npm run dev
Теперь вы можете протестировать приложение в новой сети ?!
Если у вас возникли проблемы с подключением к публичной конечной точке RPC Mumbai, замените конечные точки в вашем приложении, используя конечные точки от провайдера RPC, например Infura, Alchemy или Quicknode.
Создание API подграфа
По умолчанию единственными шаблонами доступа к данным являются две функции, которые мы прописали в контракте: fetchPost
и fetchPosts
.
Это отличное место для начала, но когда ваше приложение начнет расширяться, вы, вероятно, обнаружите, что вам нужен более гибкий и масштабируемый API.
Например, что если мы захотим дать пользователю возможность искать сообщения, получать сообщения, созданные определенным пользователем, или сортировать сообщения по дате их создания?
Мы можем встроить все эти функции в API, используя протокол The Graph. Давайте посмотрим, как это сделать.
Создание проекта в The Graph
Чтобы начать работу, зайдите на хостинг The Graph и зарегистрируйтесь или создайте новую учетную запись.
Затем перейдите на приборную панель и нажмите на Add Subgraph, чтобы создать новый подграф.
Настройте ваш подграф со следующими свойствами:
- Имя подграфа — Blogcms
- Подзаголовок — Подграф для запроса данных о постах.
- Дополнительно — заполните свойства описания и URL GITHUB.
После создания подграфа мы инициализируем его локально с помощью Graph CLI.
Инициализация нового подграфа с помощью Graph CLI
Далее установите Graph CLI:
$ npm install -g @graphprotocol/graph-cli
# or
$ yarn global add @graphprotocol/graph-cli
После установки Graph CLI вы можете инициализировать новый подграф с помощью команды Graph CLI init
.
Поскольку мы уже развернули наш контракт в сети, мы можем инициализировать с адреса этого контракта, передав адрес контракта с помощью флага --from-contract
.
Этот адрес доступен для ссылки в config.js как contractAddress
.
$ graph init --from-contract your-contract-address
--network mumbai --contract-name Blog --index-events
? Protocol: ethereum
? Product for which to initialize › hosted-service
? Subgraph name › your-username/blogcms
? Directory to create the subgraph in › blogcms
? Ethereum network › mumbai
? Contract address › your-contract-address
? ABI file (path) › artifacts/contracts/Blog.sol/Blog.json
? Contract Name › Blog
Эта команда создаст базовый подграф на основе адреса контракта, переданного в качестве аргумента команде --from-contract
. Используя этот адрес контракта, CLI инициализирует несколько вещей в вашем проекте для начала работы (включая получение abis
и сохранение их в каталоге abis).
Передавая
--index-events
, CLI автоматически заполнит некоторый код как в schema.graphql, так и в src/mapping.ts на основе событий, испускаемых контрактом.
Основная конфигурация и определение подграфа находится в файле subgraph.yaml. Кодовая база подграфа состоит из нескольких файлов:
- subgraph.yaml: файл YAML, содержащий манифест подграфа
- schema.graphql: схема GraphQL, определяющая, какие данные хранятся для вашего подграфа, и как запрашивать их через GraphQL
- AssemblyScript Mappings: Код на языке AssemblyScript, который переводит данные о событиях в Ethereum в сущности, определенные в вашей схеме (например, mapping.ts в этом учебнике).
Записи в subgraph.yaml, с которыми мы будем работать, следующие:
Определение сущностей
В The Graph вы определяете типы сущностей в schema.graphql, а Graph Node генерирует поля верхнего уровня для запроса отдельных экземпляров и коллекций этого типа сущностей. Каждый тип, который должен быть сущностью, должен быть аннотирован директивой @entity
.
Сущности / данные, которые мы будем индексировать, это Token
и User
. Таким образом, мы сможем индексировать токены, созданные пользователями, а также самих пользователей.
Для этого обновите schema.graphql следующим кодом:
type _Schema_
@fulltext(
name: "postSearch"
language: en
algorithm: rank
include: [{ entity: "Post", fields: [{ name: "title" }, { name: "postContent" }] }]
)
type Post @entity {
id: ID!
title: String!
contentHash: String!
published: Boolean!
postContent: String!
createdAtTimestamp: BigInt!
updatedAtTimestamp: BigInt!
}
Теперь, когда мы создали схему GraphQL для нашего приложения, мы можем сгенерировать сущности локально, чтобы начать использовать их в mappings
, созданных CLI:
graph codegen
Чтобы сделать работу с умными контрактами, событиями и сущностями простой и безопасной для типов, Graph CLI генерирует типы AssemblyScript на основе комбинации схемы GraphQL подграфа и ABI контрактов, включенных в источники данных.
Обновление подграфа с сущностями и связками
Теперь мы можем настроить subgraph.yaml на использование сущностей, которые мы только что создали, и настроить их сопоставления.
Для этого сначала обновите поле dataSources.mapping.entities
с сущностями User
и Token
:
entities:
- Post
Далее нам нужно будет найти блок, в котором был развернут контракт (необязательно). Это нужно для того, чтобы мы могли установить стартовый блок для индексатора, чтобы начать синхронизацию, и чтобы ему не нужно было синхронизироваться с блока genesis. Стартовый блок можно найти на сайте https://mumbai.polygonscan.com/ и вставить адрес вашего контракта.
Наконец, обновите конфигурацию, чтобы добавить startBlock
:
source:
address: "your-contract-adddress"
abi: Blog
startBlock: your-start-block
Сопоставления скриптов ассемблера
Далее откройте src/mappings.ts, чтобы записать маппинги, которые мы определили в нашем подграфе eventHandlers
.
Обновите файл следующим кодом:
import {
PostCreated as PostCreatedEvent,
PostUpdated as PostUpdatedEvent
} from "../generated/Blog/Blog"
import {
Post
} from "../generated/schema"
import { ipfs, json } from '@graphprotocol/graph-ts'
export function handlePostCreated(event: PostCreatedEvent): void {
let post = new Post(event.params.id.toString());
post.title = event.params.title;
post.contentHash = event.params.hash;
let data = ipfs.cat(event.params.hash);
if (data) {
let value = json.fromBytes(data).toObject()
if (value) {
const content = value.get('content')
if (content) {
post.postContent = content.toString()
}
}
}
post.createdAtTimestamp = event.block.timestamp;
post.save()
}
export function handlePostUpdated(event: PostUpdatedEvent): void {
let post = Post.load(event.params.id.toString());
if (post) {
post.title = event.params.title;
post.contentHash = event.params.hash;
post.published = event.params.published;
let data = ipfs.cat(event.params.hash);
if (data) {
let value = json.fromBytes(data).toObject()
if (value) {
const content = value.get('content')
if (content) {
post.postContent = content.toString()
}
}
}
post.updatedAtTimestamp = event.block.timestamp;
post.save()
}
}
Эти связки будут обрабатывать события, когда создается новый пост и когда пост обновляется. При наступлении этих событий связки будут сохранять данные в подграф.
Запуск сборки
Далее давайте запустим сборку, чтобы убедиться, что все настроено правильно. Для этого выполните команду build
:
$ graph build
Если сборка прошла успешно, вы должны увидеть новую папку сборки, созданную в вашем корневом каталоге.
Развертывание подграфа
Для развертывания мы можем выполнить команду deploy
. Для развертывания сначала нужно скопировать маркер доступа для вашей учетной записи, доступный в Graph Dashboard:
Далее выполните следующую команду:
$ graph auth
✔ Product for which to initialize · hosted-service
✔ Deploy key · ********************************
Развернуть подграф:
$ yarn deploy
После развертывания подграфа он должен появиться на приборной панели.
При нажатии на подграф откроется подробная информация о подграфе:
Запрос данных
Теперь, когда мы находимся на приборной панели, мы должны иметь возможность начать запрашивать данные. Выполните следующий запрос, чтобы получить список постов:
{
posts {
id
title
contentHash
published
postContent
}
}
Мы также можем настроить направление упорядочивания по дате создания:
{
posts(
orderBy: createdAtTimestamp
orderDirection: desc
) {
id
title
contentHash
published
postContent
}
}
Мы также можем выполнять полнотекстовый поиск по заголовку или содержанию сообщения:
{
postSearch(
text: "Hello"
) {
id
title
contentHash
published
postContent
}
}
Поздравляем, теперь вы создали более гибкий API, который можно использовать для запросов в вашем приложении!
Чтобы узнать, как использовать конечную точку API в вашем приложении, посмотрите документацию здесь или видео здесь.
Следующие шаги
Если вы хотите решить сложную задачу, подумайте о добавлении функциональности, которая позволит вам обновлять изображение обложки в pages/edit-post/[id].js.
Если вы хотите развернуть свой код на web3 / децентрализованном протоколе хостинга git, посмотрите это видео на Radicle.
Если вы хотите развернуть свое приложение и сделать его живым, посмотрите Vercel.
Если вам понравился этот учебник, обязательно ознакомьтесь с тремя другими моими исчерпывающими руководствами:
- Полное руководство по разработке полного стека Ethereum
- Создание полнофункционального рынка NFT на Ethereum с помощью Polygon
- The Complete Guide to Full Stack Solana Development with React, Anchor, Rust, and Phantomhttps://dev.to/dabit3/the-complete-guide-to-full-stack-solana-development-with-react-anchor-rust-and-phantom-3291.