译文出自:登链翻译计划[1] 译者:翻译小组[2] 校对:Tiny 熊[3]
我们将使用 Pinata, Polygon, 和 OpenSea , 创建一个应用 NFT (应用本身是一个 NFT)。

NFT 可以是游戏[4],它们也可以是 JPEG 文件[5],它们还可以是基于文本的互动游戏[6], 也可以是实用工具[7], 甚至是钥匙[8], 还有很多......很多......。
很明显,NFT 可以是任何东西。因此,当我们探索 NFT 不断发展的未来时,对 NFT 到底能成为什么进行长期思考是很有趣的。收藏品很有趣,但我如何以一种方式经营我的数字咖啡店[9],使我在这一业务中积累的价值可以像出售无聊的猿一样容易出售。答案可能是应用作为 NFT。或者,更狭义地说,将全部业务作为 NFT。
如果我的业务是一个 NFT,如果我正在用我创造的业务建立真正的价值,那么就会有一个关于这个价值的公共记录,并且有一个潜在的市场让我出售这个价值。在一个更传统的世界里,我可能必须通过一个收购公司来出售公司,而在 web3 中,如果我的企业是一个 NFT,它可能就像在 OpenSea 上挂牌出售一样简单。
因此,让我们来看看 T 创建一个应用作为一个 NF 可能是什么样子。它可能不会成为下一个大生意,但它将是一个跳板,我希望它能激发人们的好奇心、创造力和胆量。
本教程不会专注于构建应用。相反,我们将使用 React 启动器应用程序作为一个例子。本教程将重点讨论如何将该应用变成 NFT。
让我们开始吧!
开始吧
要完成本教程,你需要注册一个免费 Pinata 账户[10]。虽然使用专用网关[11]将成倍提高用户使用应用程序的体验,但我们将在本教程中使用 Pinata 的免费计划功能来开始。
你还需要一个Alchemy[12]账户,因为我们将用它来处理连接到 Polygon 节点、部署合约和铸造应用 NFT。
除了这两个账户之外,你所需要的就是一些好的传统开发工具。
- Node.js v16 或更高版本
- NPM 或 Yarn
- 代码编辑器
Hardhat[13]使管理我们的区块链交互更容易,而OpenZeppelin[14]是审计智能合约的一个出色起点。我们将利用这两者,但除了安装一些依赖性之外,你不需要做任何特别的事情。
本教程将分为两部分。首先,我们将把智能合约设置好。其次,我们将把应用链接到智能合约上,并铸造 NFT。让我们从第一部分开始。
创建合约
我们要做的第一件事是设置我们的 Hardhat 环境并创建一个项目。按照这里的指南[15]获取最新的文档,在本文写作时,步骤如下:
第一步将是创建你的项目目录(这是专门为智能合约准备的,而不是应用)。从你的命令行中运行下面的命令:
代码语言:javascript复制mkdir nft-app-contract && cd nft-app-contract
我们将需要用这个命令来初始化项目目录。
代码语言:javascript复制npm init
你可以随意回答每个提示,结果在你的项目目录中创建一个package.json文件。
接下来,我们将安装 hardhat 作为开发依赖项。
代码语言:javascript复制npm install --save-dev hardhat
安装完毕后,我们就可以使用 hardhat CLI 了,我们可以初始化一个新的 hardhat 项目。
代码语言:javascript复制npx hardhat
你会看到项目的一些模板选项。我喜欢从一个简单的启动项目开始,因为它奠定了基础。所以选择 "创建一个 Javascript 项目 "或 "创建一个 Typescript 项目"。你可以在余下的问题中点击回车,接受默认值。
这将创建一个文件夹结构,项目立刻有用,并有一些示例文件。你会看到一个contacts文件夹,一个scripts文件夹,和一个test文件夹。这个项目结构有助于让我们开始。
在继续之前,让我们安装 OpenZeppelin,以便我们能够访问他们的智能合约库。这里需要注意的是。OpenZeppelin 在安全方面是个不错的选择,因为他们的合约是经过审计的,但这些合约并不总是最节省 Gas 的。因为在本教程中使用的是 Polygon,所以我们不打算关注 Gas 效率。而且因为这个 NFT 没有数万 PFP 的空投,所需的链上交易数量会低得多,使 Gas 不再是一个问题。
运行以下命令来安装 OpenZeppelin 的库。
代码语言:javascript复制npm install @openzeppelin/contracts
现在,是时候进入正题了。让我们来写智能合约,但确保先定义我们希望这个合约做什么。这是一个应用 NFT。应该永远只有一个所有者地址。该所有者地址可能代表多人的多签钱包,但应用 NFT 将永远只属于一个地址。因此,要求:
- 在合约上铸造一个 NFT
- 访问控制元数据映射(这将很快变得更有意义)
- 更新 tokenURI 的功能(对于应用本身的更新)。
- 应用版本的映射(代表每个版本的 IPFS CID)。
我们可以从创建一个标准的 ERC721 合约开始。打开contracts文件夹,你会看到一个已经创建的合约例子。让我们把这个文件重新命名为 AppNFT.sol之类的名称。然后,打开这个文件,我们需要做一些修改。为了简单,我已经做了这些修改,下面是代码。接下来,我们将对它们进行讲解。
// SPDX-License-Identifier: MIT
pragma solidity ^ 0.8 .0;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract AppNFT is ERC721URIStorage {
using Counters
for Counters.Counter;
Counters.Counter public versions;
mapping(uint256 => string) public builds;
address public appOwner;
constructor(string memory tokenURI) ERC721("AppNFT", "APP") {
appOwner = msg.sender;
mint(tokenURI);
}
function updateApp(string memory newTokenURI) public {
require(
msg.sender == appOwner,
"Only the app owner can make this change"
);
uint256 currentVersion = versions.current();
_setTokenURI(1, newTokenURI);
builds[currentVersion 1] = newTokenURI;
versions.increment();
}
function getPreviousBuild(uint256 versionNumber)
public
view
returns(string memory) {
return builds[versionNumber];
}
function transferFrom(
address from,
address to,
uint256 tokenId
) public virtual override {
require(
_isApprovedOrOwner(_msgSender(), tokenId),
"ERC721: caller is not token owner nor approved"
);
_transfer(from, to, tokenId);
appOwner = to;
}
function mint(string memory tokenURI) private returns(uint256) {
versions.increment();
uint256 tokenId = 1;
uint256 currentVersion = versions.current();
_mint(msg.sender, tokenId);
_setTokenURI(tokenId, tokenURI);
builds[currentVersion] = tokenURI;
return tokenId;
}
}
60 行代码来构建一个应用 NFT 智能合约, 不算太寒酸。让我们看看这里发生了什么,因为这里对标准 ERC721 NFT 合约的工作方式做了一些改变。
从我们的构造函数开始,你可以看到我们做了两件事。我们把appOwner设置为部署合约的地址。这是一个简化的假设,但如果需要,可以在构造函数中传递一个地址作为参数,当合约被部署时,appOwner变量可以被设置为该地址。我们做的第二件事是立即铸造一个 NFT。
记住,这个合约只处理一个 NFT--它就是应用。所以mint函数实际上是一个私有函数。它只能由合约本身调用。如果你看一下mint函数,我们再次假设合约部署者是应用所有者,但如果你不是这种情况,你可以在合约部署中使用一个参数来指定应用所有者的地址。
既然我们已经开始讨论mint函数,我们就来看看那里发生了什么。我们有一个versions变量来跟踪应用的所有版本。立即将其递增,因为该变量开始时的默认值为 0。然后将tokenId设置为 1,这将是唯一的tokenId。我们获取当前版本,因为很快就会需要它。然后为 NFT 铸币。接下来,设置构建映射,把版本号和 tokenURI 联系起来。
最后一点是非常重要的。由于这是一个应用 NFT,我们需要能够更新应用。仅仅指定一个新版本是不够的。新版本需要指向一个新的 tokenURI。所以,通过builds变量,我们可以历史性地跟踪所有的版本。
说到这里,回到合约函数。有一个叫做updateApp的函数。这个函数接收一个新的tokenURI,它应该代表更新后应用代码的 IPFS CID。检查调用该函数的人是否是应用的所有者。
回到这个函数。updateApp函数接收一个新的 URI,用于新版本的应用,它更新了应用的版本,并将新版本映射到builds映射变量中的新 URI。它还会更新铸币的 NFT 的tokenURI。这一点很关键,因为这是唯一能让世界其他地方知道 NFT 已经改变的方法。
我们在合约上还有两个函数。第一个是getPreviousBuilds函数。任何人都可以看到应用的当前版本号。当有一个高于 1 的版本时,意味着该应用还有更早的版本。这意味着人们或其他应用甚至可以使用该应用的旧版本,如果他们愿意的话。该函数返回与作为参数传入该函数的版本相对应的 URI。
而最后一个功能实际上是对 ERC721 核心功能的重载。假设你最终出售了这个应用,因为它已经变得非常流行。人们喜欢它是一个 NFT,数据是可验证的,它可以很容易地被移动,等等。好吧,移动 NFT 的便利性是好的,但代码中的一切都取决于appOwner变量是否映射到正确的所有者。为了确保这一点,我们重载了默认的transferFrom函数,简单地将新的所有者的地址设置为合约上的appOwner。
这样,你就有了一个应用 NFT 合约。但在发布之前,我们也许应该测试它。
如果你看一下根项目文件夹,会看到test的文件夹在那里。里面会有一个测试的例子, 把文件重命名为更合适的名字,比如AppNFT.js。我们将用以下内容替换该文件中的所有内容:
const {
expect
} = require("chai");
const URI = "ipfs://QmTXCPCpdruEQ5HspoTQq6C4uJhP4V66PE89Ja7y8CEJCw";
const URI2 = "ipfs://QmTXCPwpdruEQ5HBpoTQq6C4uJhP4V66PE89Ja7y8CEJC2"
describe("AppNFT", function() {
async function deploy() {
const [owner, otherAccount] = await ethers.getSigners();
const AppNFT = await ethers.getContractFactory("AppNFT");
const appNft = await AppNFT.deploy(URI);
await appNft.deployed();
return appNft;
}
describe("Deployment", function() {
it("Should deploy contract and mint", async function() {
const appNft = await deploy();
const uri = await appNft.tokenURI(1)
expect(uri).to.equal(URI);
});
it("Should set the right version number", async function() {
const appNft = await deploy();
const versions = await appNft.versions()
expect(versions).to.equal(1);
})
it("Should return correct URI based on version", async function() {
const appNft = await deploy();
const buildURI = await appNft.getPreviousBuild(1);
expect(buildURI).to.equal(URI);
})
it("Should not allow minting additional tokens", async function() {
const appNft = await deploy();
let err;
try {
await appNft.mint(URI);
} catch (error) {
err = error.message;
}
expect(err).to.equal("appNft.mint is not a function");
})
});
describe("Versions", function() {
it("Should allow the app owner to update versions", async function() {
const appNft = await deploy();
const uri = await appNft.tokenURI(1)
expect(uri).to.equal(URI);
await appNft.updateApp(URI2);
const uri2 = await appNft.tokenURI(1)
expect(uri2).to.equal(URI2);
});
it("Should show correct current version", async function() {
const appNft = await deploy();
const uri = await appNft.tokenURI(1)
expect(uri).to.equal(URI);
await appNft.updateApp(URI2);
const uri2 = await appNft.tokenURI(1)
expect(uri2).to.equal(URI2);
});
it("Should not allow someone who is not the app owner to update versions",
async function() {
const appNft = await deploy();
const uri = await appNft.tokenURI(1)
expect(uri).to.equal(URI);
const [owner, otherAccount] = await ethers.getSigners();
let err;
try {
await appNft.connect(otherAccount).updateApp(URI2);
const uri2 = await appNft.tokenURI(1)
expect(uri2).to.equal(URI2);
} catch (error) {
err = error.message;
}
expect(err).to.equal("VM Exception while processing transaction: reverted
with reason string 'Only the app owner can make this change'
");
})
});
describe("Transfers", function() {
it("Should not allow transfers from non owner and non approved", async function() {
const appNft = await deploy();
const [owner, otherAccount] = await ethers.getSigners();
let err;
try {
await appNft.connect(otherAccount).transferFrom(owner.address,
otherAccount.address, 1);
} catch (error) {
err = error.message;
}
expect(err).to.equal("VM Exception while processing transaction: reverted
with reason string 'ERC721: caller is not token owner nor approved'
");
});
it("Should allow transfers from owner to another address", async function() {
const appNft = await deploy();
const [owner, otherAccount] = await ethers.getSigners();
await appNft.transferFrom(owner.address, otherAccount.address, 1);
expect(await appNft.appOwner()).to.equal(otherAccount.address);
expect(await appNft.ownerOf(1)).to.equal(otherAccount.address);
});
it("Should allow transfer from non-owner if address is approved", async function() {
const appNft = await deploy();
const [owner, otherAccount] = await ethers.getSigners();
await appNft.approve(otherAccount.address, 1);
await appNft.connect(otherAccount).transferFrom(owner.address,
otherAccount.address, 1);
expect(await appNft.appOwner()).to.equal(otherAccount.address);
expect(await appNft.ownerOf(1)).to.equal(otherAccount.address);
})
})
});
这里不打算逐行阅读测试代码,但只知道这是一个开始。你可以随心所欲地扩展这些测试,测试 ERC721 的核心功能,或者实现这些测试的其他变化。但作为一个起点,我们可以用它来确保核心功能工作没问题。
当这些测试的编写完之后,我们可以利用 hardhat CLI 来编译我们的合约并在内存中运行测试。在命令行中,在项目的根目录下,运行以下命令:
代码语言:javascript复制npx hardhat test
如果一切顺利,你应该看到类似这样的东西:

测试已经通过,让我们休息一下。给自己准备一杯冷饮,当你回来的时候,我们要把应用代码上传到 IPFS,这样就可以继续部署合约和铸造应用 NFT 了。
应用
对于应用,我们只是要用标准的 React 启动。所以,从命令行中,打开一个新的窗口,或者从智能合约目录中换出来。然后,运行以下命令:
代码语言:javascript复制npx create-react-app app-nft-frontend
当安装完成后,切换到该目录,在代码编辑器中打开它。
在本教程中,我们不打算花时间定制该应用。相反,我们要使它进入一个可以上传项目到 Pinata 的状态。为了方便,我们将创建一个部署脚本,使项目能够从命令行建立并上传到 Pinata。
所以,需要做的第一件事是让 IPFS 理解这个 React 应用是如何工作的,并能正确渲染页面路由。要做到这一点,需要更新的package.json。打开该文件,并在 JSON 中添加以下键/值对。
"homepage": "./"
为了创建一个脚本来构建和部署应用,需要获得一个 Pinata API 密钥。所以,登录你的 Pinata 账户,进入API 密钥页面[16]。在那里,你可以点击新密钥按钮。我们将使用 Pinata 的一个 CLI 工具,它需要 admin 权限,所以要创建一个 admin key。
给你的 key 起个名字,然后创建它。你会看到一个包含 API Key、API Secret 和 JWT 的模式。拷贝下 JWT。现在,让我们从 Pinata 安装一个有用的 CLI 工具来上传文件。
代码语言:javascript复制npm i -g pinata-upload-cli
这将在全局范围内安装 CLI,所以可以在任何项目的任何地方使用它。我们需要进行认证,所以在工具安装完毕后,运行:
代码语言:javascript复制pinata-cli -a JWT_HERE
一旦通过认证,我们就可以编写我们的 shell 脚本来执行上传。回到你的 React 应用项目,再次打开package.json文件。在scripts部分,添加一个名为 deploy的新脚本,像这样:
"deploy": "npm run build && sh ./upload.sh"
这个脚本,构建了应用,然后它使用sh来执行一个我们仍然需要编写的 shell 脚本。注意:你可能需要使用bash来执行,所以如果这不起作用,请删除sh,用bash代替。
现在,在项目的根目录下,创建一个名为upload.sh的文件。在该文件中,添加这一行:
pinata-cli -u ./build
我们正在使用 Pinata 上传 CLI 工具来上传构建文件夹。就这么简单。
现在,运行以下命令:
代码语言:javascript复制npm run deploy
首先,构建脚本将运行,然后上传 CLI 将上传构建文件夹。当命令执行完成后,你会看到一个包括 IPFS CID(哈希值)的输出。猜猜这个哈希值是什么?
没错,它将帮助我们为 NFT 制作tokenURI。
不过,这个哈希值并不完全是代币的 URI。还需要为它创建一个元数据文件。我们很快就能在合约目录中做到这一点。
让我们通过部署智能合约和铸造我们的 NFT 来完成这整个事情。
制作一个应用 NFT
我们有了 IPFS 的 CID 来作为tokenURI。现在,我们需要部署合约。我们将使用 Alchemy 节点与 Polygon 的测试网版本进行对话,因此登录 Alchemy 账户并创建一个新的应用。选择 Polygon 并选择 Mumbai testnet。
一旦你这样做了,你就可以点击查看密钥按钮,你会看到一个 HTTPS URL。复制这个 URL,很快就会需要它。
打开合约项目目录,找到hardhat.config.js文件。你要更新配置部分,使其看起来像这样:
require("@nomicfoundation/hardhat-toolbox");
module.exports = {
solidity: "0.8.9",
};
module.exports = {
solidity: "0.8.9",
networks: {
mumbai: {
url: `https://polygon-mumbai.g.alchemy.com/v2/YOUR_ALCHEMY_KEY`,
accounts: ["YOUR POLYGON MUMBAI WALLET PRIVATE KEY"]
}
}
};
确保在url字段中使用你从 Alchemy 复制的 HTTPS URL。你会发现你还需要一个私钥。任何时候你在测试和研究教程时,一般来说,最好是创建一个全新的钱包。如果你最终不小心把私钥提交到 Github,你只是提交了你为这个项目专门制作的密钥。
你可以在 Metamask 中创建一个新的钱包。要从该钱包导出你的私钥,点击账户详情,然后点击导出私钥。把该密钥放在配置文件中的 accounts数组中。
再次,要非常小心,不要把这个密钥用于任何真正的资产,并始终保护你的私钥。
一旦你更新了你的配置文件,你可以在这里[17]获取一些测试网的 Matic
记住,我们需要为 NFT 创建元数据文件。元数据是向世界描述 NFT 的东西。它将所有东西联系在一起。在这里,你可以包括几乎所有你想要的对你的应用 NFT 有重要性的东西。但有几个内容我们必须包括:
- 名称
- 描述
- 图片
- 动画 URL(animation_url)
前三个是非常直接的。它们是你在向应用商店提交应用或在某个地方推广它时预期需要的东西。对于应用的 NFT 也是如此。你需要提供信息,以便人们能够发现和了解它是什么。
不过,第四个属性是什么呢?这个属性是OpenSea 首先识别的东西,是他们的 NFT 元数据标准的一部分[18]。这就是我们如何确保应用 NFT(在主网环境中)会显示出来,并且可以在 OpenSea 中使用。我们要把应用的 IPFS CID(在构建和上传应用到 Pinata 时收到的哈希值)放在这里。
在你的合约项目根目录下创建一个名为metadata.json的文件并添加以下内容:
{
"name": "App NFT",
"description": "A full application, accessible as an NFT, sold as an NFT, and transferred as an NFT",
"image": "ipfs://CID_FOR_IMAGE_REPRESENTING_YOUR_APP",
"animation_url": "ipfs://HASH_FROM_APP_BUILD"
}
对于image属性,找一张能代表你的应用的图片,并将其上传到 Pinata。然后,你可以用ipfs://CID_FOR_THAT_IMAGE填充image值。
现在,使用方便的 Pinata 上传工具来上传元数据文件。
在你的命令行中,从你的合约项目目录下运行这个:
代码语言:javascript复制pinata-cli -u ./metadata.json
把上传的哈希值保留下来。我们马上就会需要它。
现在我们已经有了一切,可以编写部署脚本,部署我们的合约,并为我们的应用 NFT 铸币。部署脚本应该是非常简单的,因为它只是你已经为你的测试案例写的部署代码的一个变体。在scripts文件夹中也有一个例子文件,你可以看一下,它叫做deploy.js。把这个文件修改成:
const hre = require("hardhat");
const URI = "ipfs://METADATA_CID"
async function main() {
const AppNFT = await ethers.getContractFactory("AppNFT");
const appNft = await AppNFT.deploy(URI);
await appNft.deployed();
console.log(`Contract deployed to ${appNft.address}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
你复制的哈希值可以放在METADATA_CID占位符的地方。现在,你已经准备好部署了。让我们用 Hardhat 的内存以太坊 EVM 做一个测试运行,只需要运行以下命令:
npx hardhat run scripts/deploy.js
不会花费任何 Gas,因为这只是你电脑上的一个本地测试网,结果应该是这样的:
代码语言:javascript复制Contract deployed to 0x5FbDB2315678afecq367f032d93F642f64170aa3
一切看起来都很好。假设你已经得到了你的 testnet matic,并且按照上面的 hardhat 配置文件说明,你已经准备好部署你的智能合约, 让我们稍微修改一下部署命令。运行下面的命令:
代码语言:javascript复制npx hardhat run scripts/deploy.js --network mumbai
完成后,你会看到与之前相同的输出,但有一个不同的合约地址。另一个区别是,这个合约实际上被部署到了 Polygon Mumbai 的测试网,你可以在浏览器上验证[19]。在那里搜索合约地址,你会看到该合约已经被部署。你也可以点击进入部署交易,看到这个合约上唯一的 NFT 被铸造了。
我们刚刚推出了一个应用 NFT! 现在剩下的就是在 OpenSea 上看到它。我们需要从 OpenSea 的 testnet 网站上查看。如果你去 opensea 测试站[20]并使用你用来部署合约的同一个钱包连接 Metamask,你将能够轻松找到你的 NFT。点击右上方的个人资料按钮,然后点击我的收藏。它应该自动出现。下面是我这里看起来的样子:

而当我点击收藏并点击进入一个铸币的 NFT 时,我看到了这个:

正如你所看到的,我这里是一个没有定制 React 启动器的应用,但假设你做了,你会看到一个更酷的应用。这个应用是一个 NFT。它可以在 OpenSea 上工作,但它也可以通过 IPFS 网关访问该应用的 IPFS CID 来工作。
总结
为了提高性能和稳定性,我强烈建议考虑一个付费的 Pinata 计划,这样你就可以创建一个专用网关[21]。一个专用网关将给你一个全球 CDN,这意味着你的应用每次都能快速加载,这在广泛传播时尤其重要。
这是一个简单的应用 NFT 的例子,但希望它能向你展示什么是可能的。想象一下,以这种方式创建一个数百万美元的业务。想象一下,仅仅通过在 OpenSea 上上架这个应用 NFT,就能够出售该业务。如果我们允许自己把 NFT 想成不仅仅是图像,那么一个全新的机会和创造力的世界就可以被打开了。
本翻译由 Duet Protocol[22] 赞助支持。
原文: https://medium.com/pinata/how-to-build-an-app-nft-7c57b51698e7
参考资料
[1]
登链翻译计划: https://github.com/lbc-team/Pioneer
[2]
翻译小组: https://learnblockchain.cn/people/412
[3]
Tiny 熊: https://learnblockchain.cn/people/15
[4]
NFT可以是游戏: https://www.cryptokitties.co/
[5]
可以是JPEG文件: https://cryptopunks.app/
[6]
基于文本的互动游戏: https://www.lootproject.com/
[7]
也可以是实用工具: https://www.entrepreneur.com/article/424793
[8]
甚至是钥匙: https://unlock-protocol.com/
[9]
我的数字咖啡店: https://www.pinata.cloud/blog/ceo-deep-dive-why-nfts-are-not-all-the-same?utm_source=medium&utm_medium=blog-post&utm_campaign=&utm_content=nft-app
[10]
免费Pinata账户: https://pinata.cloud/pricing?utm_source=medium&utm_medium=blog-post&utm_campaign=&utm_content=nft-app
[11]
专用网关: https://www.pinata.cloud/dedicated-gateways?utm_source=medium&utm_medium=blog-post&utm_campaign=&utm_content=nft-app
[12]
Alchemy: https://www.alchemy.com/
[13]
Hardhat: https://hardhat.org/
[14]
OpenZeppelin: https://www.openzeppelin.com/
[15]
按照这里的指南: https://hardhat.org/tutorial/setting-up-the-environment
[16]
API密钥页面: https://app.pinata.cloud/keys
[17]
你可以在这里: https://mumbaifaucet.com/
[18]
OpenSea首先识别的东西,是他们的NFT元数据标准的一部分: https://docs.opensea.io/docs/metadata-standards
[19]
你可以在浏览器上验证: https://mumbai.polygonscan.com/
[20]
如果你去opensea 测试站: https://testnets.opensea.io/
[21]
专用网关: https://www.pinata.cloud/dedicated-gateways?utm_source=medium&utm_medium=blog-post&utm_campaign=&utm_content=nft-app
[22]
Duet Protocol: https://duet.finance/?utm_souce=learnblockchain


