目录

1、ERC721的基础知识

1.1、什么是不可替代代币?

NFT 是独一无二的,每个令牌都有独特的特征和价值。可以成为 NFT 的东西类型有收藏卡、艺术品、机票等,它们之间都有明显的区别,不可互换。将不可替代代币 (NFT) 视为稀有收藏品;而且大多数时候,还有它的元数据属性。

1.2、什么是 ERC-721?

ERC-721(Ethereum Request for Comments 721)由 William Entriken、Dieter Shirley、Jacob Evans 和 Nastassia Sachs 于 2018 年 1 月提出,是一种不可替代的代币标准。描述了如何在 EVM(以太坊虚拟机)兼容的区块链上构建不可替代的代币;它是不可替代代币的标准接口;它有一套规则,可以很容易地使用 NFTNFT 不仅是 ERC-721 类型;它们也可以是ERC-1155 令牌。

ERC-721 引入了 NFT 标准,换句话说,这种类型的 Token 是独一无二的,并且可能具有与来自同一智能合约的另一个 Token 不同的价值,可能是由于它的年龄、稀有性甚至是其他类似自定义属性等等。

所有 NFT 都有一个 uint256 类型的变量 tokenId,因此对于任何 ERC-721 合约,该对 contract addressuint256 tokenId 必须是全局唯一的。也就是说,一个 dApp 可以有一个“转换器”,它使用 tokenId 作为输入并输出一些很酷的东西的图像,比如僵尸、武器、技能或猫、狗一类的!

1.3、什么是元数据

所有 NFT 都有元数据。您可以在最初的ERC/EIP 721 提案中了解这一点 。 基本上,社区发现在以太坊上存储图像真的很费力而且很昂贵。如果你想存储一张 8 x 8 的图片,存储这么多数据是相当便宜的,但如果你想要一张分辨率不错的图片,你就需要花更多的 GAS 费用。

虽然 以太坊 2.0 将解决很多这些扩展难题,但目前,社区需要一个标准来帮助解决这个问题,这也就是元数据的存在原因。

{
    "name": "mshk-logo-black",
    "description": "mshk.top logo black",
    "image": "https://bafybeihodzhbtntgml7t72maxill576ssax6md5kfu72aq4gd4p53oipn4.ipfs.infura-ipfs.io/",
    "attributes": [
        {
            "trait_type": "customAttr",
            "value": 100
        }
    ]
}

name,NFT的代币名称
description,NFT的代币描述
image,NFT图像的URL
attributes,NFT代币的属性,可以定义多个

一旦我们将 tokenId 分配给他们的 tokenURINFT 市场将能够展示你的代币,您可以在 Rinkeby 测试网上的 OpenSea 市场上看到我使用元数据更新后的效果。类似展示 NFT 的市场 还有如 MintableRarible
mshk.top

1.4、如何在链上保存NFT的图像

您会在上面的元数据代码示例中注意到,图像使用指向 IPFSURL,这是一种流行的图像存储方式。

IPFS 代表星际文件系统,是一种点对点超媒体协议,旨在使网络更快、更安全、更开放。它允许任何人上传文件,并且该文件被散列,因此如果它发生变化,它的散列也会发生变化。这是存储图像的理想选择,因为这意味着每次更新图像时,链上的 hash/tokenURI 也必须更改,这意味着我们可以记录元数据的历史记录。将图像添加到 IPFS 上也非常容易,并且不需要运行服务器!

推荐使用 CoinTool 中的 IPFS 工具

2、HardHat

关于 HardHat 的介绍以及安装,可以参考文章 如何使用ERC20代币实现买、卖功能并完成Dapp部署

3、创建项目

3.1、创建 NFT 市场

进入 hardhat 项目目录,创建 contracts/ERC721/NftMarketplace.sol 文件,内容如下:

$ cat contracts/ERC721/NftMarketplace.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.14;

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

// Check out https://github.com/Fantom-foundation/Artion-Contracts/blob/5c90d2bc0401af6fb5abf35b860b762b31dfee02/contracts/FantomMarketplace.sol
// For a full decentralized nft marketplace

// 从Solidity v0.8.4开始,有一种方便且省 gas 的方式可以通过使用自定义错误向用户解释操作失败的原因。
// 错误的语法类似于 事件的语法。它们必须与revert 语句一起使用,这会导致当前调用中的所有更改都被还原并将错误数据传递回调用者
error PriceNotMet(address nftAddress, uint256 tokenId, uint256 price);
error ItemNotForSale(address nftAddress, uint256 tokenId);
error NotListed(address nftAddress, uint256 tokenId);
error AlreadyListed(address nftAddress, uint256 tokenId);
error NoProceeds();
error NotOwner();
error NotApprovedForMarketplace();
error PriceMustBeAboveZero();

contract NftMarketplace is ReentrancyGuard {
    // 保存卖家地址和价格
    struct Listing {
        uint256 price;
        address seller;
    }

    // 加入市场列表事件
    event ItemListed(
        address indexed seller,
        address indexed nftAddress,
        uint256 indexed tokenId,
        uint256 price
    );

    // 更新事件
    event UpdateListed(
        address indexed seller,
        address indexed nftAddress,
        uint256 indexed tokenId,
        uint256 price
    );

    // 取消市场列表事件
    event ItemCanceled(
        address indexed seller,
        address indexed nftAddress,
        uint256 indexed tokenId
    );

    // 买入事件
    event ItemBuy(
        address indexed buyer,
        address indexed nftAddress,
        uint256 indexed tokenId,
        uint256 price
    );

    // 保存NFT列表和卖家的对应状态
    mapping(address => mapping(uint256 => Listing)) private s_listings;

    // 卖家地址和卖出的总金额
    mapping(address => uint256) private s_proceeds;

    modifier notListed(
        address nftAddress,
        uint256 tokenId,
        address owner
    ) {
        Listing memory listing = s_listings[nftAddress][tokenId];
        if (listing.price > 0) {
            revert AlreadyListed(nftAddress, tokenId);
        }
        _;
    }

    // 检查卖家是否在列表中
    modifier isListed(address nftAddress, uint256 tokenId) {
        Listing memory listing = s_listings[nftAddress][tokenId];
        if (listing.price <= 0) {
            revert NotListed(nftAddress, tokenId);
        }
        _;
    }

    // 检查 NFT 地址的 tokenId owner 是否为 spender
    modifier isOwner(
        address nftAddress,
        uint256 tokenId,
        address spender
    ) {
        IERC721 nft = IERC721(nftAddress);

        // 查找NFT的所有者,分配给零地址的 NFT 被认为是无效的,返回NFT持有者地址
        address owner = nft.ownerOf(tokenId);
        if (spender != owner) {
            revert NotOwner();
        }
        _;
    }

    /*
     * @notice 将 NFT 加入到市场列表中,external 表示这是一个外部函数
     * @param nftAddress Address of NFT contract
     * @param tokenId Token ID of NFT
     * @param price sale price for each item
     */
    function listItem(
        address nftAddress,
        uint256 tokenId,
        uint256 price
    )
        external
        notListed(nftAddress, tokenId, msg.sender)
        isOwner(nftAddress, tokenId, msg.sender)
    {
        if (price <= 0) {
            // 终止运行并撤销状态更改
            revert PriceMustBeAboveZero();
        }
        IERC721 nft = IERC721(nftAddress);
        // 获取单个NFT的批准地址,如果tokenId不是有效地址,抛出异常,
        if (nft.getApproved(tokenId) != address(this)) {
            revert NotApprovedForMarketplace();
        }

        // 存储智能合约状态
        s_listings[nftAddress][tokenId] = Listing(price, msg.sender);

        // 注册事件
        emit ItemListed(msg.sender, nftAddress, tokenId, price);
    }

    /*
     * @notice 从NFT列表中删除 卖家信息
     * @param nftAddress Address of NFT contract
     * @param tokenId Token ID of NFT
     */
    function cancelListing(address nftAddress, uint256 tokenId)
        external
        isOwner(nftAddress, tokenId, msg.sender)
        isListed(nftAddress, tokenId)
    {
        delete (s_listings[nftAddress][tokenId]);

        // 注册 事件
        emit ItemCanceled(msg.sender, nftAddress, tokenId);
    }

    /*
     * @notice 允许买家使用ETH,从卖家列表中买入 NFT
     * nonReentrant 方法 防止合约被重复调用
     * @param nftAddress NFT 合约地址
     * @param tokenId NFT 的通证 ID
     */
    function buyItem(address nftAddress, uint256 tokenId)
        external
        payable
        isListed(nftAddress, tokenId)
        nonReentrant
    {
        // 获取卖家列表,并判断支付的ETH是否小于卖家的价格
        Listing memory listedItem = s_listings[nftAddress][tokenId];
        if (msg.value < listedItem.price) {
            revert PriceNotMet(nftAddress, tokenId, listedItem.price);
        }

        // 更新卖家卖出的金额
        s_proceeds[listedItem.seller] += msg.value;
        // Could just send the money...
        // https://fravoll.github.io/solidity-patterns/pull_over_push.html

        // 从卖家列表中删除
        delete (s_listings[nftAddress][tokenId]);

        // 将 NFT(tokenId) 所有权从 listedItem.seller 转移到  msg.sender
        IERC721(nftAddress).safeTransferFrom(
            listedItem.seller,
            msg.sender,
            tokenId
        );

        //注册买家事件
        emit ItemBuy(msg.sender, nftAddress, tokenId, listedItem.price);
    }

    /*
     * @notice 卖家更新NFT在市场上的价格
     * @param nftAddress Address of NFT contract
     * @param tokenId Token ID of NFT
     * @param newPrice Price in Wei of the item
     */
    function updateListing(
        address nftAddress,
        uint256 tokenId,
        uint256 newPrice
    )
        external
        isListed(nftAddress, tokenId)
        nonReentrant
        isOwner(nftAddress, tokenId, msg.sender)
    {
        s_listings[nftAddress][tokenId].price = newPrice;
        emit UpdateListed(msg.sender, nftAddress, tokenId, newPrice);
    }

    /*
     * @notice 将ETH转移到其他帐号,同时设置收益余额为0
     */
    function withdrawProceeds() external {
        uint256 proceeds = s_proceeds[msg.sender];
        if (proceeds <= 0) {
            revert NoProceeds();
        }
        s_proceeds[msg.sender] = 0;

        // 将 ETH 发送到地址的方法,关于此语法更多介绍可以参考下面链接
        // https://ethereum.stackexchange.com/questions/96685/how-to-use-address-call-in-solidity
        (bool success, ) = payable(msg.sender).call{value: proceeds}("");
        require(success, "Transfer failed");
    }

    /*
     * @notice 获取NFT卖家列表
     */
    function getListing(address nftAddress, uint256 tokenId)
        external
        view
        returns (Listing memory)
    {
        return s_listings[nftAddress][tokenId];
    }

    // 获取 seller 卖出的总金额
    function getProceeds(address seller) external view returns (uint256) {
        return s_proceeds[seller];
    }
}

Solidity v0.8.4开始,有一种方便且省 GAS 的方式可以通过使用自定义错误向用户解释操作失败的原因。错误的语法类似于事件的语法。它们必须与 revert 语句一起使用,这会导致当前调用中的所有更改都被还原并将错误数据传递回调用者。
  自定义错误是在智能合约主体之外声明的。当错误被抛出时,在 Solidity 中意味着当某些检查和条件失败,周围函数的执行被“还原”。

代码中主要内容介绍:

  • notListed、isListed、isOwner是函数修饰符的应用。
  • listItem方法,将 NFT 加入到列表,会做一些权限验证。其中用到了函数修饰符事件
  • cancelListing方法,从列表中删除 NFT,将 NFT 下架。
  • buyItem方法,购买 NFT ,项目中主要用 ETH 来交换 NFT 资产,也可以用其他数字资产进行交换。同时会更新卖家余额。从listItem中下架 NFT
  • updateListing方法,更新 NFT 的价格。
  • withdrawProceeds方法,将卖出的收益从合约中转移给卖家。
  • getListing方法,根据 NFT 地址和 tokenId,返回卖家和价格信息。
  • getProceeds方法,查看卖家卖出后的收益。

3.2、创建 NFT 智能合约

在编写测试脚本前,我们需要一个 NFT的智能合约示例,以便我们铸造的 NFT可以在市场上展示、销售。我们将遵守 ERC721 令牌规范,我们将从 OpenZeppelinERC721URIStorage 库继承。

进入 hardhat 项目目录,创建 contracts/ERC721/MSHK721NFT.sol 文件,内容如下:

$ cat contracts/ERC721/MSHK721NFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.14;

import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "hardhat/console.sol";

contract MSHK721NFT is ERC721URIStorage, Ownable {
    // 递增递减计数器
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;

    // 声明事件
    event NFTMinted(uint256 indexed tokenId);

    constructor() ERC721("MSHKNFT", "MyNFT") {}

    /**
     * 制作NFT,返回铸造的 NFT ID
     * @param recipient 接收新铸造NFT的地址.
     * @param tokenURI 描述 NFT 元数据的 JSON 文档
     */
    function mintNFT(address recipient, string memory tokenURI)
        external
        onlyOwner
        returns (uint256)
    {
        // 递增
        _tokenIds.increment();

        // 获取当前新的 TokenId
        uint256 newTokenId = _tokenIds.current();

        // 铸造NFT
        _safeMint(recipient, newTokenId);

        // 保存NFT URL
        _setTokenURI(newTokenId, tokenURI);

        // 注册事件
        emit NFTMinted(newTokenId);

        return newTokenId;
    }

    function getTokenCounter() public view returns (uint256) {
        return _tokenIds.current();
    }
}

上面的代码中,通过 mintNFT 方法铸造 NFT,主要有2个参数,第1个参数是接收NFT 的地址,第2个参数是 NFTURL 地址,也就是上文中提到的元数据地址。

3.3、编写测试脚本

在编写测试脚本前,我们先通过 IPFS工具,上传我们的图片和元数据文件,下面是我们已经上传好的2个元数据文件:

文件1,内容如下:

{
    "name": "mshk-logo-black",
    "description": "mshk.top logo black",
    "image": "https://bafybeihodzhbtntgml7t72maxill576ssax6md5kfu72aq4gd4p53oipn4.ipfs.infura-ipfs.io/",
    "attributes": [
        {
            "trait_type": "customAttr",
            "value": 100
        }
    ]
}

文件2,内容如下:

{
    "name": "mshk-logo-blue",
    "description": "mshk.top logo blue",
    "image": "https://bafybeifxkvzedhwclmibidf5hjoodwqkk2vlbbrlhd3bxbl3wzmkmyrvpq.ipfs.infura-ipfs.io/",
    "attributes": [
        {
            "trait_type": "customAttr",
            "value": 200
        }
    ]
}

进入 hardhat 项目目录,创建 test/ERC721/01_NFT.js 测试文件,内容如下:

const { expect } = require("chai");
const { ethers } = require("hardhat");

/**
 * 运行测试方法:
 * npx hardhat test test/ERC721/01_NFT.js
 */
describe("NFT MarketPlace Test", () => {


    // NFT 元数据1
    const TOKEN_URI1 = "https://bafybeif5jtlbetjp2nzj64gstexywpp53efr7yynxf4qxtmf5lz6seezia.ipfs.infura-ipfs.io";
    // NFT 元数据2
    const TOKEN_URI2 = "https://bafybeibyb2rdn6raav4ozyxub2r5w4vh3wmw46s6bi54eq7syjzfkmbjn4.ipfs.infura-ipfs.io";

    let owner;
    let addr1;
    let addr2;
    let addrs;

    let nftMarketplaceContractFactory;
    let nftContractFactory;
    let nftMarketplaceContract;
    let nftContract;

    let IDENTITIES;

    beforeEach(async () => {
        [owner, addr1, addr2, ...addrs] = await ethers.getSigners();

        IDENTITIES = {
            [owner.address]: "OWNER",
            [addr1.address]: "DEPLOYER",
            [addr2.address]: "BUYER_1",
        }

        var NFTMarketplaceContractName = "NftMarketplace";
        var NFTContractName = "MSHK721NFT"

        // 获取 NFTMarketplace 实例
        nftMarketplaceContractFactory = await ethers.getContractFactory(NFTMarketplaceContractName);
        // 部署 NFTMarketplace 合约
        nftMarketplaceContract = await nftMarketplaceContractFactory.deploy()

        // 获取 nftContract 实例
        nftContractFactory = await ethers.getContractFactory(NFTContractName);
        // 部署 nftContract 合约
        nftContract = await nftContractFactory.deploy()

        console.log(`owner:${owner.address}`)
        console.log(`addr1:${addr1.address}`)
        console.log(`addr2:${addr2.address}`)

        //
        console.log(`${NFTMarketplaceContractName} Token Contract deployed address -> ${nftMarketplaceContract.address}`);

        //
        console.log(`${NFTContractName} Token Contract deployed address -> ${nftContract.address} owner:${await nftContract.owner()}`);

    });

    it("mint and list and buy item", async () => {

        console.log(`Minting NFT for ${addr1.address}`)
        // 为 addr1 铸造一个 NFT
        let mintTx = await nftContract.connect(owner).mintNFT(addr1.address, TOKEN_URI1)
        let mintTxReceipt = await mintTx.wait(1)


        // 非常量(既不pure也不view)函数的返回值仅在函数被链上调用时才可用(即,从这个合约或从另一个合约)
        // 当从链下(例如,从 ethers.js 脚本)调用此类函数时,需要在交易中执行它,并且返回值是该交易的哈希值,因为不知道交易何时会被挖掘并添加到区块链中
        // 为了在从链下调用非常量函数时获得它的返回值,可以发出一个包含将要返回的值的事件
        let tokenId = mintTxReceipt.events[0].args.tokenId


        expect(tokenId).to.equal(1);

        // 授权 市场合约 可以操作这个NFT
        console.log("Approving Marketplace as operator of NFT...")
        let approvalTx = await nftContract
            .connect(addr1)
            .approve(nftMarketplaceContract.address, tokenId)
        await approvalTx.wait(1)

        // NFT交易价格 10 ETH
        let PRICE = ethers.utils.parseEther("10")

        // 将 NFT 加入到列表
        console.log("Listing NFT...")
        let listItemTX = await nftMarketplaceContract
            .connect(addr1)
            .listItem(nftContract.address, tokenId, PRICE)
        await listItemTX.wait(1)
        console.log("NFT Listed with token ID: ", tokenId.toString())

        const mintedBy = await nftContract.ownerOf(tokenId)

        // 检查 nft 的 owner 是否为 addr1
        expect(mintedBy).to.equal(addr1.address)

        console.log(`NFT with ID ${tokenId} minted and listed by owner ${mintedBy} with identity ${IDENTITIES[mintedBy]}. `)

        //---- Buy 

        // 根据 tokenId 获取 NFT
        let listing = await nftMarketplaceContract.getListing(nftContract.address, tokenId)
        let price = listing.price.toString()

        // 使用 addr2    从 nftMarketplaceContract 买入 TOKEN_ID 为 0 的NFT
        const buyItemTX = await nftMarketplaceContract
            .connect(addr2)
            .buyItem(nftContract.address, tokenId, {
                value: price,
            })
        await buyItemTX.wait(1)
        console.log("NFT Bought!")

        const newOwner = await nftContract.ownerOf(tokenId)
        console.log(`New owner of Token ID ${tokenId} is ${newOwner} with identity of ${IDENTITIES[newOwner]} `)

        //---- proceeds
        const proceeds = await nftMarketplaceContract.getProceeds(addr1.address)

        const proceedsValue = ethers.utils.formatEther(proceeds.toString())
        console.log(`Seller ${owner.address} has ${proceedsValue} eth!`)

        //---- withdrawProceeds
        const addr1OldBalance = await ethers.provider.getBalance(addr1.address);
        await nftMarketplaceContract.connect(addr1).withdrawProceeds()
        const addr1NewBalance = await ethers.provider.getBalance(addr1.address);
        console.log(`${addr1.address}  old:${ethers.utils.formatEther(addr1OldBalance)} eth,withdrawProceeds After:${ethers.utils.formatEther(addr1NewBalance)} eth!`)

    });


    it("update and cancel nft item", async () => {
        // 为 addr2 铸造一个 NFT
        let mintTx = await nftContract.connect(owner).mintNFT(addr2.address, TOKEN_URI2)
        let mintTxReceipt = await mintTx.wait(1)


        // 非常量(既不pure也不view)函数的返回值仅在函数被链上调用时才可用(即,从这个合约或从另一个合约)
        // 当从链下(例如,从 ethers.js 脚本)调用此类函数时,需要在交易中执行它,并且返回值是该交易的哈希值,因为不知道交易何时会被挖掘并添加到区块链中
        // 为了在从链下调用非常量函数时获得它的返回值,可以发出一个包含将要返回的值的事件
        let tokenId = mintTxReceipt.events[0].args.tokenId

        // 授权 市场合约 可以操作这个NFT
        console.log("Approving Marketplace as operator of NFT...")
        approvalTx = await nftContract.connect(addr2).approve(nftMarketplaceContract.address, tokenId)
        await approvalTx.wait(1)

        // NFT交易价格 0.1 ETH
        PRICE = ethers.utils.parseEther("0.1")

        // 将 NFT 加入到列表
        console.log("Listing NFT...")
        listItemTX = await nftMarketplaceContract.connect(addr2).listItem(nftContract.address, tokenId, PRICE)
        await listItemTX.wait(1)
        console.log("NFT Listed with token ID: ", tokenId.toString())


        console.log(`Updating listing for token ID ${tokenId} with a new price`)

        listing = await nftMarketplaceContract.getListing(nftContract.address, tokenId)
        let oldPrice = listing.price.toString()
        console.log(`oldPrice:  ${ethers.utils.formatEther(oldPrice.toString())}`)

        // 更新价格
        const updateTx = await nftMarketplaceContract.connect(addr2).updateListing(nftContract.address, tokenId, ethers.utils.parseEther("0.5"))

        // 等待链上处理
        const updateTxReceipt = await updateTx.wait(1)

        // 从事件中获取更新的价格
        const updatedPrice = updateTxReceipt.events[0].args.price
        console.log(`updated price:  ${ethers.utils.formatEther(updatedPrice.toString())}`)

        // 获取信息,确认价格是否有变更.
        const updatedListing = await nftMarketplaceContract.getListing(
            nftContract.address,
            tokenId
        )
        console.log(`Updated listing has price of ${ethers.utils.formatEther(updatedListing.price.toString())}`)

        //----------cancel
        let tx = await nftMarketplaceContract.connect(addr2).cancelListing(nftContract.address, tokenId)
        await tx.wait(1)
        console.log(`NFT with ID ${tokenId} Canceled...`)

        // Check cancellation.
        const canceledListing = await nftMarketplaceContract.getListing(nftContract.address, tokenId)
        console.log("Seller is Zero Address (i.e no one!)", canceledListing.seller)
    });

});

上面的测试脚本中,我们分成两部分,注释比较详细,下面是简要介绍这两部分测试的功能。
  第1部分:

  • addr1 用户铸1个NFT
  • 授权 NFT市场 可以操作这个 addr1 的 NFT。
  • NFT 加入到 NFT市场,设置价格为 10 ETH。
  • 使用 addr2 用户购买 addr1 的NFT。
  • 查看addr1NFT市场 的余额
  • NFT市场中的余额取出到 addr1 的余额,对比前后余额数据。

第2部分:

  • addr2 用户铸1个NFT
  • 授权 NFT市场 可以操作这个 addr2 的 NFT。
  • NFT 加入到 NFT市场,设置价格为 0.1 ETH。
  • addr2 的NFT价格从 0.1 ETH 更新为 0.5 ETH。进行数据对比输出。
  • NFT市场 中下架 addr2 的 NFT。

下面是我们运行测试脚本的效果:
mshk.top

到目前为止,我们已经完成了 NFT 的创建,并将 NFT 加入到市场完成了买、卖、查看销售后的余额,转帐给卖家等功能。

项目的源码都保存在 Github:https://github.com/idoall/NFT-ERC721-NFTMarketPlace

克隆项目到本地后,进入 hardhat 项目目录,先执行 yarn install 下载依赖包。

$ yarn install
yarn install v1.22.19
warning package.json: No license field
warning package-lock.json found. Your project contains lock files generated by tools other than Yarn. It is advised not to mix package managers in order to avoid resolution inconsistencies caused by unsynchronized lock files. To clear this warning, remove package-lock.json.
warning hardhat-project: No license field
[1/4] 
    

  
内容来源于网络如有侵权请私信删除

文章来源: 博客园

原文链接: https://www.cnblogs.com/lion.net/p/16555552.html

你还没有登录,请先登录注册
  • 还没有人评论,欢迎说说您的想法!