Skip to main content

创建一个元数据存储在 IPFS 上的 NFT 集合

现在是时候让你启动你自己的 NFT 集合并将其元数据存储在 IPFS 上了

要求

  • 应该只存在 10 个 LearnWeb3 Punk NFT,而且每一个都应该是唯一的。
  • 用户应该只能用一个交易来铸造一个 NFT。
  • NFT 的元数据应该存储在 IPFS 上。
  • 应该有一个网站用于收集 NFT。
  • NFT 合约应该部署在 Mumbai 的测试网上。

让我们开始构建 🚀

先决条件

你应该已经完成了 IPFS 理论教程

构建

IPFS

首先,我们需要将我们的图片和元数据上传到 IPFS 上。我们将使用一个叫 Pinata 的服务,它将帮助我们在 IPFS 上上传和固定内容。一旦你注册了,进入 Pinata 控制面板,点击 "上传",然后点击 "文件夹"。

LW3Punks 文件夹下载到你的电脑上,然后上传到 Pinata,将文件夹命名为 LW3Punks

现在你应该能看到你的文件夹的 CID 了,真棒!图片

你可以通过打开这个来检查它是否真的被上传到了 IPFS:https://ipfs.io/ipfs/your-nft-folder-cid,用你从Pinata那里得到的CID替换你的nft-folder-cid。

你的 NFT 的图片现在已经上传到了 IPFS,但是仅仅有图片是不够的,每个 NFT 还应该有相关的元数据。

我们现在将为每个 NFT 上传元数据到 IPFS,每个元数据文件将是一个 json 文件。下面给出了 NFT 1 的元数据例子。

{
"name": "1",
"描述": "为 LearnWeb3 的学生收集 NFT",
"图像": "ipfs://QmQBHarz2WFczTjz5GnhjHrbUPDnB48W5BM2v2h6HbE1rZ/1.png"
}

注意 "image "中的 ipfs 位置,而不是 https 网址。还要注意的是,由于你上传了一个文件夹,你还需要指定该文件夹中的哪个文件对给定的 NFT 具有正确的图像。因此,在我们的案例中,指定 NFT 图像位置的正确方法是 ipfs://CID-OF-THE-LW3Punks-Folder/NFT-NAME.png。

我们已经为你预先生成了元数据文件,你可以从这里将它们下载到你的电脑上,将这些文件上传到 pinata 并命名为 metadata 文件夹

现在每个 NFT 的元数据都已经上传到了 IPFS,pinata 应该已经为你的元数据文件夹 Image 生成了一个 CID。

你可以通过打开这个来检查它是否真的被上传到了 IPFS:https://ipfs.io/ipfs/your-metadata-folder-cid 把你的 metadata-folder-cid 替换成你从 pinata 收到的 CID。

复制这个 CID 并保存在你的记事本上,在接下来的教程中你会需要它。

合约

对于合约,我们将使用一个简单的 NFT 合约。我们还将使用 Openzeppelin 的 Ownable.sol,它可以帮助你管理合约的所有权。

默认情况下,Ownable 合约的所有者是部署它的账户,这通常正是你想要的。Ownable 还可以让你

  • 将所有权从所有者账户转移到一个新的账户,以及
  • renounceOwnership,让所有者放弃这个管理权限,这是在集中管理的初始阶段结束后的一个常见模式。

我们还将使用 ERC721 的一个扩展,称为 ERC721 Enumerable

ERC721 Enumerable 是帮助你跟踪合约中的所有 tokenIds,以及一个地址为特定合约所持有的 tokenIds。在继续前进之前,请看一下它实现的功能

为了构建智能合约,我们将使用 Hardhat。Hardhat 是一个 Ethereum 开发环境和框架,为 Solidity 的全栈开发而设计。简单地说,你可以编写你的智能合约,部署它们,运行测试,并调试你的代码。

要设置一个 Hardhat 项目,请打开终端并执行这些命令

mkdir nft-ipfs
cd nft-ipfs
mkdir hardhat
cd hardhat
npm init --yes
npm install --save-dev hardhat
npx hardhat # 初始化项目
npm i @openzeppelin/contracts dotenv

在同一个终端,现在安装@openzeppelin/contracts,因为我们将在我们的 LW3Punks 合约中导入 Openzeppelin 的 ERC721Enumerable 合约。

现在让我们在 nft-ipfs/hardhat/contracts 目录下创建一个新文件,并将其称为 LW3Punks.sol

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

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

contract LW3Punks is ERC721Enumerable, Ownable {
using Strings for uint256;
/**
* @dev _baseTokenURI for computing {tokenURI}. If set, the resulting URI for each
* token will be the concatenation of the `baseURI` and the `tokenId`.
*/
string _baseTokenURI;

// _price is the price of one LW3Punks NFT
uint256 public _price = 0.01 ether;

// _paused is used to pause the contract in case of an emergency
bool public _paused;

// max number of LW3Punks
uint256 public maxTokenIds = 10;

// total number of tokenIds minted
uint256 public tokenIds;

modifier onlyWhenNotPaused {
require(!_paused, "Contract currently paused");
_;
}

/**
* @dev ERC721 constructor takes in a `name` and a `symbol` to the token collection.
* name in our case is `LW3Punks` and symbol is `LW3P`.
* Constructor for LW3P takes in the baseURI to set _baseTokenURI for the collection.
*/
constructor (string memory baseURI) ERC721("LW3Punks", "LW3P") {
_baseTokenURI = baseURI;
}

/**
* @dev mint allows an user to mint 1 NFT per transaction.
*/
function mint() public payable onlyWhenNotPaused {
require(tokenIds < maxTokenIds, "Exceed maximum LW3Punks supply");
require(msg.value >= _price, "Ether sent is not correct");
tokenIds += 1;
_safeMint(msg.sender, tokenIds);
}

/**
* @dev _baseURI overides the Openzeppelin's ERC721 implementation which by default
* returned an empty string for the baseURI
*/
function _baseURI() internal view virtual override returns (string memory) {
return _baseTokenURI;
}

/**
* @dev tokenURI overides the Openzeppelin's ERC721 implementation for tokenURI function
* This function returns the URI from where we can extract the metadata for a given tokenId
*/
function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token");

string memory baseURI = _baseURI();
// Here it checks if the length of the baseURI is greater than 0, if it is return the baseURI and attach
// the tokenId and `.json` to it so that it knows the location of the metadata json file for a given
// tokenId stored on IPFS
// If baseURI is empty return an empty string
return bytes(baseURI).length > 0 ? string(abi.encodePacked(baseURI, tokenId.toString(), ".json")) : "";
}

/**
* @dev setPaused makes the contract paused or unpaused
*/
function setPaused(bool val) public onlyOwner {
_paused = val;
}

/**
* @dev withdraw sends all the ether in the contract
* to the owner of the contract
*/
function withdraw() public onlyOwner {
address _owner = owner();
uint256 amount = address(this).balance;
(bool sent, ) = _owner.call{value: amount}("");
require(sent, "Failed to send Ether");
}

// Function to receive Ether. msg.data must be empty
receive() external payable {}

// Fallback function is called when msg.data is not empty
fallback() external payable {}
}

如前面的教程添加.env 配合

进入 Quicknode 并注册一个账户。如果你已经有一个账户,请登录。Quicknode 是一个节点提供者,让你连接到各种不同的区块链。我们将使用它来通过 Hardhat 部署我们的合约。创建一个账户后,在 Quicknode 上创建一个端点,选择 Polygon,然后选择 Mumbai 网络。点击右下方的 "继续",然后点击 "创建端点"。复制 HTTP 提供者中给你的链接,并将其添加到下面 QUICKNODE_HTTP_URL 的 .env 文件中。

QUICKNODE_HTTP_URL="add-quicknode-http-provider-url-here"
PRIVATE_KEY="add-the-private-key-here"

修改部署代码

scripts/deploy.js,通过 metadataURL 将部署的合约和 IPFS 中的资源进行关联。

const { ethers } = require("hardhat");
require("dotenv").config({ path: ".env" });

async function main() {
// URL from where we can extract the metadata for a LW3Punks
const metadataURL = "ipfs://YOUR-METADATA-CID/";
/*
A ContractFactory in ethers.js is an abstraction used to deploy new smart contracts,
so lw3PunksContract here is a factory for instances of our LW3Punks contract.
*/
const lw3PunksContract = await ethers.getContractFactory("LW3Punks");

// deploy the contract
const deployedLW3PunksContract = await lw3PunksContract.deploy(metadataURL);

await deployedLW3PunksContract.deployed();

// print the address of the deployed contract
console.log("LW3Punks Contract Address:", deployedLW3PunksContract.address);
}

// Call the main function and catch if there is any error
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

修改配置文件

hardhat.config.js

require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config({ path: ".env" });

const QUICKNODE_HTTP_URL = process.env.QUICKNODE_HTTP_URL;
const PRIVATE_KEY = process.env.PRIVATE_KEY;

module.exports = {
solidity: "0.8.4",
networks: {
mumbai: {
url: QUICKNODE_HTTP_URL,
accounts: [PRIVATE_KEY],
},
},
};

编译部署

npx hardhat compile
npx hardhat run scripts/deploy.js --network mumbai

网站

创建网站并安装依赖

npx create-next-app@latest
npm i web3modal ethers

下载图片

在你的公共文件夹中,下载【这个文件夹](https://github.com/LearnWeb3DAO/IPFS-Practical/tree/master/my-app/public/LW3punks)和其中的所有图片,即LW3Punks文件夹。确保下载的文件夹的名称是LW3Punks

修改样式

styles/Home.modules.css

.main {
min-height: 90vh;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
font-family: "Courier New", Courier, monospace;
}

.footer {
display: flex;
padding: 2rem 0;
border-top: 1px solid #eaeaea;
justify-content: center;
align-items: center;
}

.image {
width: 70%;
height: 50%;
margin-left: 20%;
}

.title {
font-size: 2rem;
margin: 2rem 0;
}

.description {
line-height: 1;
margin: 2rem 0;
font-size: 1.2rem;
}

.button {
border-radius: 4px;
background-color: blue;
border: none;
color: #ffffff;
font-size: 15px;
padding: 20px;
width: 200px;
cursor: pointer;
margin-bottom: 2%;
}
@media (max-width: 1000px) {
.main {
width: 100%;
flex-direction: column;
justify-content: center;
align-items: center;
}
}

修改页面

pages/index.js

import { Contract, providers, utils } from "ethers";
import Head from "next/head";
import React, { useEffect, useRef, useState } from "react";
import Web3Modal from "web3modal";
import { abi, NFT_CONTRACT_ADDRESS } from "../constants";
import styles from "../styles/Home.module.css";

export default function Home() {
// walletConnected keep track of whether the user's wallet is connected or not
const [walletConnected, setWalletConnected] = useState(false);
// loading is set to true when we are waiting for a transaction to get mined
const [loading, setLoading] = useState(false);
// tokenIdsMinted keeps track of the number of tokenIds that have been minted
const [tokenIdsMinted, setTokenIdsMinted] = useState("0");
// Create a reference to the Web3 Modal (used for connecting to Metamask) which persists as long as the page is open
const web3ModalRef = useRef();

/**
* publicMint: Mint an NFT
*/
const publicMint = async () => {
try {
console.log("Public mint");
// We need a Signer here since this is a 'write' transaction.
const signer = await getProviderOrSigner(true);
// Create a new instance of the Contract with a Signer, which allows
// update methods
const nftContract = new Contract(NFT_CONTRACT_ADDRESS, abi, signer);
// call the mint from the contract to mint the LW3Punks
const tx = await nftContract.mint({
// value signifies the cost of one LW3Punks which is "0.01" eth.
// We are parsing `0.01` string to ether using the utils library from ethers.js
value: utils.parseEther("0.01"),
});
setLoading(true);
// wait for the transaction to get mined
await tx.wait();
setLoading(false);
window.alert("You successfully minted a LW3Punk!");
} catch (err) {
console.error(err);
}
};

/*
connectWallet: Connects the MetaMask wallet
*/
const connectWallet = async () => {
try {
// Get the provider from web3Modal, which in our case is MetaMask
// When used for the first time, it prompts the user to connect their wallet
await getProviderOrSigner();
setWalletConnected(true);
} catch (err) {
console.error(err);
}
};

/**
* getTokenIdsMinted: gets the number of tokenIds that have been minted
*/
const getTokenIdsMinted = async () => {
try {
// Get the provider from web3Modal, which in our case is MetaMask
// No need for the Signer here, as we are only reading state from the blockchain
const provider = await getProviderOrSigner();
// We connect to the Contract using a Provider, so we will only
// have read-only access to the Contract
const nftContract = new Contract(NFT_CONTRACT_ADDRESS, abi, provider);
// call the tokenIds from the contract
const _tokenIds = await nftContract.tokenIds();
console.log("tokenIds", _tokenIds);
//_tokenIds is a `Big Number`. We need to convert the Big Number to a string
setTokenIdsMinted(_tokenIds.toString());
} catch (err) {
console.error(err);
}
};

/**
* Returns a Provider or Signer object representing the Ethereum RPC with or without the
* signing capabilities of metamask attached
*
* A `Provider` is needed to interact with the blockchain - reading transactions, reading balances, reading state, etc.
*
* A `Signer` is a special type of Provider used in case a `write` transaction needs to be made to the blockchain, which involves the connected account
* needing to make a digital signature to authorize the transaction being sent. Metamask exposes a Signer API to allow your website to
* request signatures from the user using Signer functions.
*
* @param {*} needSigner - True if you need the signer, default false otherwise
*/
const getProviderOrSigner = async (needSigner = false) => {
// Connect to Metamask
// Since we store `web3Modal` as a reference, we need to access the `current` value to get access to the underlying object
const provider = await web3ModalRef.current.connect();
const web3Provider = new providers.Web3Provider(provider);

// If user is not connected to the Mumbai network, let them know and throw an error
const { chainId } = await web3Provider.getNetwork();
if (chainId !== 80001) {
window.alert("Change the network to Mumbai");
throw new Error("Change network to Mumbai");
}

if (needSigner) {
const signer = web3Provider.getSigner();
return signer;
}
return web3Provider;
};

// useEffects are used to react to changes in state of the website
// The array at the end of function call represents what state changes will trigger this effect
// In this case, whenever the value of `walletConnected` changes - this effect will be called
useEffect(() => {
// if wallet is not connected, create a new instance of Web3Modal and connect the MetaMask wallet
if (!walletConnected) {
// Assign the Web3Modal class to the reference object by setting it's `current` value
// The `current` value is persisted throughout as long as this page is open
web3ModalRef.current = new Web3Modal({
network: "mumbai",
providerOptions: {},
disableInjectedProvider: false,
});

connectWallet();

getTokenIdsMinted();

// set an interval to get the number of token Ids minted every 5 seconds
setInterval(async function () {
await getTokenIdsMinted();
}, 5 * 1000);
}
}, [walletConnected]);

/*
renderButton: Returns a button based on the state of the dapp
*/
const renderButton = () => {
// If wallet is not connected, return a button which allows them to connect their wallet
if (!walletConnected) {
return (
<button onClick={connectWallet} className={styles.button}>
Connect your wallet
</button>
);
}

// If we are currently waiting for something, return a loading button
if (loading) {
return <button className={styles.button}>Loading...</button>;
}

return (
<button className={styles.button} onClick={publicMint}>
Public Mint 🚀
</button>
);
};

return (
<div>
<Head>
<title>LW3Punks</title>
<meta name="description" content="LW3Punks-Dapp" />
<link rel="icon" href="/favicon.ico" />
</Head>
<div className={styles.main}>
<div>
<h1 className={styles.title}>Welcome to LW3Punks!</h1>
<div className={styles.description}>
Its an NFT collection for LearnWeb3 students.
</div>
<div className={styles.description}>
{tokenIdsMinted}/10 have been minted
</div>
{renderButton()}
</div>
<div>
<img className={styles.image} src="./LW3punks/1.png" />
</div>
</div>

<footer className={styles.footer}>Made with &#10084; by LW3Punks</footer>
</div>
);
}

添加常量配置

constants/index.js

export const NFT_CONTRACT_ADDRESS = "address of your NFT contract";
export const abi = "---your abi---";

运行部署