Web3.0:构建 NFT 市场(一)

1,878 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第 22 天,点击查看活动详情

WEB3.0,最近火热的一个关键词,说到WEB3.0,其中少不了 NFT ,为了更好的理解 NFT ,本文通过创建一个 NFT 市场项目来熟悉 NFT 的铸造及市场机制,该项目将基于 Polygon 网络上的 NFT 市场。

现在来快速解释一下 Polygon 网络和 NFT 是什么。

基础知识

NFT 代表不可替代代币,如果是加密货币领域的一员,可能听说过这种新的数字资产。今天看到的大多数 NFT 都是存在于区块链上的图像或短 gif。

虽然任何人都可以截取 NFT,但他们没有所有权证明。把它想象成给一幅名画拍照,它没有价值,NFT 的价值来自所有权。区块链上的合约为这些 NFT 分配唯一地址。即使有人从屏幕截图中创建了另一个 NFT,区块链的历史也将证明它不是原创的。

如果使用最大和最流行的可编程加密货币以太坊,创建 NFT 可能会很昂贵(手续费比较昂贵)。

Polygon (正式名称为 Matic)被称为 Layer-2 区块链。它本质上是对以太坊的升级,它降低了价格并提高了交易速度

初始化和配置

首先打开命令终端并输入 npx create-next-app crayon-nft-marketplace,命令将使用 Next.js(一个用于生产的 React 框架)来创建一个带有预配置文件(例如 pages/index.js)的简单应用程序模板。

创建文件夹和一些相应的文件后,就该安装依赖项了:

npm install ethers hardhat @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers web3modal  axios --save
npm install @openzeppelin/contracts --save

IPFS 相关的依赖需要单独安装:

npm install ipfs-http-client@56.0.1 --save

在UI方面,使用 Tailwind CSS,安装依赖:

npm install  tailwindcss@latest postcss@latest autoprefixer@latest -D
npx tailwindcss init -p

智能合约

现在开始编写智能合约,是 NFT 的核心部分。项目根目录下执行下面指令:

npx hardhat

选择创建一个空的 hardhat.config.js 文件。

根据 Infura 文档,添加了一个 hardhat 网络,这里将选择 chainId1337 的测试链。

module.exports = {
    solidity: "0.7.3",
    networks: {
        hardhat: {
            chainId: 1337,
        },
    },
};

接下来,继续根据 Infura 提供给 Polygon 主网和 Polygon Mumbai 测试网 URL

module.exports = {
    solidity: "0.7.3",
    networks: {
        hardhat: {
            chainId: 80001,
        },
        mumbai: {
            url: "https://rpc-mumbai.maticvigil.com/",
        },
        mainnet: {
            url: "https://polygon-rpc.com/",
        },
    },
};

项目根目录下创建 .secret 文件,用于存储 Metamask 钱包的私钥,然后在使用的地方导入即可。然后修改文件 hardhat.config.js ,增加私钥导入和修改 solidity 的版本信息,代码如下:

const fs = require("fs");
require("@nomiclabs/hardhat-waffle");
const privateKey = fs.readFileSync(".secret").toString().trim();

module.exports = {
    solidity: "0.8.4",
    networks: {
        hardhat: {
            chainId: 80001,
        },
        mumbai: {
            url: "https://rpc-mumbai.maticvigil.com/",
            accounts: [privateKey],
        },
        mainnet: {
            url: "https://polygon-rpc.com/",
            accounts: [privateKey],
        },
        ropsten: {
            url: `https://ropsten.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161`,
            accounts: [privateKey],
            chainId: 3,
        },
    },
};

创建智能合约

上面完成了合约所需的环境,现在开始创建智能合约。构建 NFT 及 NFT 市场需要两个智能合约,一个用于创建 NFT,另一个用于查看这些 NFT 的交易。在项目根目录下创建文件夹 contracts,在文件夹中创建两个合约文件 CrayonNft.solCrayonNftMarket.sol,合约导入了 OpenZeppelin 文件,可以访问已经创建的合约。

来看下创建NFT的合约 CrayonNft.sol

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

合约导入的内容用途介绍如下:

  • ERC721.sol:核心和元数据扩展,具有基本 URI 机制
  • ERC721URIStorage.sol:一种更灵活但更昂贵的元数据存储方式。
  • Counters.sol:一种获取只能递增、递减或重置的计数器的简单方法。对于 ID 生成、合同活动计数等非常有用。

CrayonNft.sol 合约是最简单的一个,合约实现允许用户通过获取文件 URL 并将其推送到存储在区块链上的数组来铸造 NFT,然后在铸造 NFT 时增加这个数组。完整代码如下:

// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract CrayonNft is ERC721URIStorage {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;
    address contractAddress;

    constructor(address marketplaceAddress) ERC721("Crayon Nft Tokens", "METT") {
        contractAddress = marketplaceAddress;
    }

    function createToken(string memory tokenURI) public returns (uint) {
        _tokenIds.increment();
        uint256 newItemId = _tokenIds.current();

        _mint(msg.sender, newItemId);
        _setTokenURI(newItemId, tokenURI);
        setApprovalForAll(contractAddress, true);
        return newItemId;
    }
}

现在来看合约 CrayonNftMarket.sol

import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; 
import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 

合约导入的内容用途介绍如下:

  • ERC721.sol:核心和元数据扩展,具有基本 URI 机制
  • Counters.sol:一种获取只能递增、递减或重置的计数器的简单方法。对于 ID 生成、合同活动计数等非常有用。
  • ReentrancyGuard.sol:在某些功能期间可以防止重入的修饰符。
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; 
import "hardhat/console.sol";

contract CrayonNftMarket is ReentrancyGuard {
    using Counters for Counters.Counter;
    Counters.Counter private _itemIds; 
    Counters.Counter private _itemsSold;

    address payable owner;
    uint256 listingPrice = 0.025 ether;

    constructor() {
        owner = payable(msg.sender);
    }

    // 定义 NFT 销售属性
    struct MarketItem {
        uint itemId;
        address nftContract;
        uint256 tokenId;
        address payable seller;
        address payable owner;
        uint256 price;
        bool sold;
    }

    mapping(uint256 => MarketItem) private idToMarketItem;

    // 市场项目创建触发器
    event MarketItemCreated (
        uint indexed itemId,
        address indexed nftContract,
        uint256 indexed tokenId,
        address seller,
        address owner,
        uint256 price,
        bool sold
    );

    // 返回价格
    function getListingPrice() public view returns (uint256) {
        return listingPrice;
    }
    
    // 在市场上销售一个NFT
    function createMarketItem(
        address nftContract,
        uint256 tokenId,
        uint256 price
    ) public payable nonReentrant {
        require(price > 0, "Price must be at least 1 wei"); // 防止免费交易
        require(msg.value == listingPrice, "Price must be equal to listing price"); // 交易价格必须和NFT单价相等

        _itemIds.increment();
        uint256 itemId = _itemIds.current();

        idToMarketItem[itemId] =  MarketItem(
            itemId,
            nftContract,
            tokenId,
            payable(msg.sender),
            payable(address(0)),
            price,
            false
        );

        IERC721(nftContract).transferFrom(msg.sender, address(this), tokenId); // 将NFT的所有权转让给合同

        emit MarketItemCreated(
            itemId,
            nftContract,
            tokenId,
            msg.sender,
            address(0),
            price,
            false
        );
    }
    
    // 创建销售市场项目转让所有权和资金
    function createMarketSale(
        address nftContract,
        uint256 itemId
        ) public payable nonReentrant {
        uint price = idToMarketItem[itemId].price;
        uint tokenId = idToMarketItem[itemId].tokenId;
        
        require(msg.value == price, "Please submit the asking price in order to complete the purchase"); // 如果要价不满足会不会产生误差

        idToMarketItem[itemId].seller.transfer(msg.value); // 将价值转移给卖方
        idToMarketItem[itemId].owner = payable(msg.sender);
        idToMarketItem[itemId].sold = true;
        _itemsSold.increment();
        console.log("Nft Contract Address:",nftContract);
        console.log("tokenId:",tokenId);
        payable(owner).transfer(listingPrice);
    }

    // 返回市场上所有未售出的商品
    function fetchMarketItems() public view returns (MarketItem[] memory) {
        uint itemCount = _itemIds.current();
        uint unsoldItemCount = _itemIds.current() - _itemsSold.current(); // 更新数量
        uint currentIndex = 0;

        MarketItem[] memory items = new MarketItem[](unsoldItemCount); // 如果地址为空(未出售的项目),将填充数组
        for (uint i = 0; i < itemCount; i++) {
            if (idToMarketItem[i + 1].owner == address(0)) {  
                uint currentId =  i + 1;
                MarketItem storage currentItem = idToMarketItem[currentId]; 
                items[currentIndex] = currentItem;
                currentIndex += 1;
            } 
        }
        return items;
    }

    // 获取用户购买的NFT
    function fetchMyNFTs() public view returns (MarketItem[] memory) {
        uint totalItemCount = _itemIds.current();
        uint itemCount = 0;
        uint currentIndex = 0;

        for (uint i = 0; i < totalItemCount; i++) {
            for (uint j = 0; j < totalItemCount; j++) {
                itemCount += 1;
            }
        }

        MarketItem[] memory items = new MarketItem[](itemCount);
        for (uint256 i = 0; i < totalItemCount; i++) {
            if (idToMarketItem[i + 1].owner == msg.sender) {
                uint currentId =  i + 1;
                MarketItem storage currentItem = idToMarketItem[currentId];
                items[currentIndex] = currentItem;
                currentIndex += 1;
            }
        }
        return items;
    }

    // 获取卖家制作的NFT
    function fetchItemsCreated() public view returns (MarketItem[] memory) {
        uint totalItemCount = _itemIds.current();
        uint itemCount = 0;
        uint currentIndex = 0;

        for (uint i = 0; i < totalItemCount; i++) {
            if (idToMarketItem[i + 1].seller == msg.sender) {
                itemCount += 1;
            }
        }

        MarketItem[] memory items = new MarketItem[](itemCount); 
        for (uint i = 0; i < totalItemCount; i++) {
            if (idToMarketItem[i + 1].seller == msg.sender) {
                uint currentId = i + 1;
                MarketItem storage currentItem = idToMarketItem[currentId];
                items[currentIndex] = currentItem;
                currentIndex += 1;
            }
        }
        return items;
    }
}

合约 CrayonNftMarket.sol 定义了正在出售的 NFT、转移资金和所有权、防止多次请求、防止零价值交易,并允许用户查看自己的收藏。

测试智能合约

上面完成了两个智能合约,现在开始编写测试代码,并进行测试。在项目根目录下创建 test 文件夹,并在文件夹中创建文件 test.js,测试代码主要是模拟构建 NFT,并将其投放市场进行交易。测试代码需要依赖框架 ChaiHardhat

首先测试模拟部署NFT市场合约,代码逻辑是先找到相应的合约,部署它,然后获取部署后的地址,代码如下:

// 模拟部署NFT市场合约
const nftMarket = await ethers.getContractFactory("CrayonNftMarket");
const market = await nftMarket.deploy();
await market.deployed(); // 等待市场合约部署
const marketAddress = market.address; // 获取市场合约部署地址

每个成功部署的智能合约都有自己的地址。接下来模拟部署 NFT 合约,和上面的逻辑一样,代码如下:

// 模拟部署NFT合约
const nftContract = await ethers.getContractFactory("CrayonNft");
const nft = await nftContract.deploy(marketAddress);
await nft.deployed();
const nftContractAddress = nft.address; // 获取NFT合约部署地址

上面的代码逻辑将创建NFT的合约部署到在运行的NFT市场,并等待返回 NFT 地址。随着测试NFT市场合约和 创建 NFT 合约的部署,将获取发布到交易市场价格将其转换为以太币(一个完整的以太坊代币),而不是使用 ethers.js 的 Gwei(在实际部署中,将使用 matic 而不是 ethers 作为价格单位)。

const auctionPrice = ethers.utils.parseUnits("100", "ether");

测试代码中还将创建两个 NFT,并将其推送到交易市场,代码如下:

// 创建两个NFT Token
await nft.createToken("https://resources.crayon.dev/nfts/logo.jpg");
await nft.createToken("https://resources.crayon.dev/nfts/share.jpg");
// 将两个NFT 推送到市场进行交易
await market.createMarketItem(nftContractAddress, 1, auctionPrice, {
    value: listingPrice,
});
await market.createMarketItem(nftContractAddress, 2, auctionPrice, {
    value: listingPrice,
});

下面的代码将获取 NFT 地址,将它们放入正确的数组位置,然后获取交易价格。交易价格以以太币为单位。

// 查询并返回未售出的NFT
let arrayItems = await market.fetchMarketItems();

arrayItems = await Promise.all(
    arrayItems.map(async (i) => {
        const tokenUri = await nft.tokenURI(i.tokenId);
        return {
            price: i.price.toString(),
            tokenId: i.tokenId.toString(),
            seller: i.seller,
            owner: i.owner,
            tokenUri,
        };
    })
);
console.log("未销售的NFT: ", arrayItems);

测试完整代码如下:

require("chai");
const { ethers } = require("hardhat");
describe("NFTMarket 测试", function () {
    it("创建NFT并投入市场", async () => {
        // 模拟部署NFT市场合约
        const nftMarket = await ethers.getContractFactory("CrayonNftMarket");
        const market = await nftMarket.deploy();
        await market.deployed(); // 等待市场合约部署
        const marketAddress = market.address; // 获取市场合约部署地址

        // 模拟部署NFT合约
        const nftContract = await ethers.getContractFactory("CrayonNft");
        const nft = await nftContract.deploy(marketAddress);
        await nft.deployed();
        const nftContractAddress = nft.address; // 获取NFT合约部署地址

        let listingPrice = await market.getListingPrice(); // 获取市场价格
        listingPrice = listingPrice.toString();
        // 将发布到市场的交易价格转为 ETH ,而不是 Gwei
        const auctionPrice = ethers.utils.parseUnits("100", "ether");

        // 创建两个NFT Token
        await nft.createToken("https://resources.crayon.dev/nfts/logo.jpg");
        await nft.createToken("https://resources.crayon.dev/nfts/share.jpg");

        // 将两个NFT 推送到市场进行交易
        await market.createMarketItem(nftContractAddress, 1, auctionPrice, {
            value: listingPrice,
        });
        await market.createMarketItem(nftContractAddress, 2, auctionPrice, {
            value: listingPrice,
        });
        /*
            在现实世界的中,用户将通过Metamask等数字钱包与合约进行交互。
            在测试环境中,将使用由Hardhat提供的本地地址进行交互
        */
        const [_, buyerAddress] = await ethers.getSigners();

        // 执行Token(即NFT)销售给另一个用户
        await market
            .connect(buyerAddress)
            .createMarketSale(nftContractAddress, 1, { value: auctionPrice });

        // 查询并返回未售出的NFT
        let arrayItems = await market.fetchMarketItems();

        arrayItems = await Promise.all(
            arrayItems.map(async (i) => {
                const tokenUri = await nft.tokenURI(i.tokenId);
                return {
                    price: i.price.toString(),
                    tokenId: i.tokenId.toString(),
                    seller: i.seller,
                    owner: i.owner,
                    tokenUri,
                };
            })
        );
        console.log("未销售的NFT: ", arrayItems);
    });
});

接下来执行测试命令:

npx hardhat test

即可看到测试结果,不出意外是直接通过。