逐步创建 NFT 集合的教程
👋 引言
非同质化代币(NFT)已成为数字艺术和收藏品世界中最热门的话题之一。NFT是使用区块链技术验证所有权和真实性的独特数字资产。它们为创作者和收藏家提供了将数字艺术、音乐、视频和其他形式的数字内容货币化和交易的新可能性。近年来,NFT市场爆炸性增长,一些高调的销售额达到了数百万美元。在本文中,我们将逐步在TON上构建我们的NFT集合。
这是你在本教程结束时将创建的鸭子集合的精美图片:
🦄 你将会学到什么
- 你将在TON上铸造NFT集合
- 你将理解TON上的NFT是如何工作的
- 你将把NFT出售
- 你将把元数据上传到pinata.cloud
💡 必要条件
你必须已经有一个测试网钱包,里面至少有2 TON。可以从@testgiver_ton_bot获取测试网币。
:::info[如何打开我的Tonkeeper钱包的测试网版本?] 要在tonkeeper中打开测试网网络,请转到设置并点击位于底部的tonkeeper logo 5次,之后选择测试网而不是主网。 :::
我们将使用Pinata作为我们的IPFS存储系统,因此你还需要在pinata.cloud上创建一个帐户并获取api_key & api_secreat。官方Pinata 文档教程可以帮助完成这一点。只要你拿到这些api令牌,我就在这里等你!!!
💎 什么是 TON 上的 NFT?
在开始我们教程的主要部分之前,我们需要了解一下通常意义上TON中NFT是如何工作的。出乎意料的是,我们将从解释ETH中NFT的工作原理开始,为了理解TON中NFT实现的特殊性,与行业中常见的区块链相比。
ETH 上的 NFT 实现
ETH中NFT的实现极其简单 - 存在1个主要的集合合约,它存储一个简单的哈希映射,该哈希映射反过来存储此集合中NFT的数据。所有与此集合相关的请求(如果任何用户想要转移NFT、将其出售等)都特别发送到此1个集合合约。
在 TON 中如此实现可能出现的问题
在TON的上下文中,此类实现的问题由TON的NFT标准完美描述:
-
不可预测的燃料消耗。在TON中,字典操作的燃料消耗取决于确切的键集。此外,TON是一个异步区块链。这意味着,如果你向一个智能合约发送一个消息,那么你不知道有多少来自其他用户的消息会在你的消息之前到达智能合约。因此,你不知道当你的消息到达智能合约时字典的大小会是多少。这对于简单的钱包 -> NFT智能合约交互是可以的,但对于智能合约链,例如钱包 -> NFT智能合约 -> 拍卖 -> NFT智能合约,则不可接受。如果我们不能预测燃料消耗,那么可能会出现这样的情况:NFT智能合约上的所有者已经更改,但拍卖操作没有足够的Toncoin。不使用字典的智能合约可以提供确定性的燃料消耗。
-
不可扩展(成为瓶颈)。TON的扩展性基于分片的概念,即在负载下自动将网络划分为分片链。流行NFT的单个大智能合约与这一概念相矛盾。在这种情况下,许多交易将引用一个单一的智能合约。TON架构为分片的智能合约提供了设施(参见白皮书),但目前尚未实现。
简而言之,ETH的解决方案不可扩展且不适用于像TON这样的异步区块链。
TON 上的 NFT 实现
在TON中,我们有1个主合约-我们集合的智能合约,它存储它的元数据和它所有者的地址,以及最重要的 - 如果我们想要创建("铸造")新的NFT项目 - 我们只需要向这个集合合约发送消息。而这个集合合约将为我们部署新NFT项目的合约,并提供我们提供的数据。
如果你想更深入地了解这个话题,可以查看TON上的NFT处理文章或阅读NFT标准
⚙ 设置开发环境
让我们从创建一个空项目开始:
- 创建新文件夹
mkdir MintyTON
mkdir MintyTON
- 将以下配置复制到tsconfig.json中
cd MintyTON
- 向package.json添加脚本以构建并启动我们的应用程序
yarn init -y
- 安装所需的库
yarn add typescript @types/node -D
- 创建
.env
文件并根据此模板添加你自己的数据
tsc --init
- 将以下配置复制到tsconfig.json中
{
"compilerOptions": {
"module": "commonjs",
"target": "es6",
"lib": ["ES2022"],
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist",
"baseUrl": "src",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"strict": true,
"esModuleInterop": true,
"strictPropertyInitialization": false
},
"include": ["src/**/*"]
}
- 在
package.json
中添加脚本以构建和启动我们的应用程序
"scripts": {
"start": "tsc --skipLibCheck && node dist/app.js"
},
- 安装所需的库
yarn add @pinata/sdk dotenv @ton/ton @ton/crypto @ton/core buffer
- 创建
.env
文件并根据此模板添加你自己的数据
PINATA_API_KEY=your_api_key
PINATA_API_SECRET=your_secret_api_key
MNEMONIC=word1 word2 word3 word4
TONCENTER_API_KEY=aslfjaskdfjasasfas
最后打开我们的钱包:
太好 了!现在我们准备好开始为我们的项目编写代码了。
编写辅助函数
首先,让我们在 src/utils.ts
中创建函数 openWallet
,它将通过助记符打开我们的钱包,并返回钱包的公钥/密钥。
最后,让我们创建delay.ts
文件,在这个文件中,我们将创建一个函数来等待seqno
增加。
import { KeyPair, mnemonicToPrivateKey } from "@ton/crypto";
import { beginCell, Cell, OpenedContract} from "@ton/core";
import { TonClient, WalletContractV4 } from "@ton/ton";
export type OpenedWallet = {
contract: OpenedContract<WalletContractV4>;
keyPair: KeyPair;
};
export async function openWallet(mnemonic: string[], testnet: boolean) {
const keyPair = await mnemonicToPrivateKey(mnemonic);
创建一个类实例以与toncenter交互:
const toncenterBaseEndpoint: string = testnet
? "https://testnet.toncenter.com"
: "https://toncenter.com";
const client = new TonClient({
endpoint: `${toncenterBaseEndpoint}/api/v2/jsonRPC`,
apiKey: process.env.TONCENTER_API_KEY,
});
元数据 - 只是一些简单的信息,将描述我们的NFT或集合。例如它的名称、它的描述等。
const wallet = WalletContractV4.create({
workchain: 0,
publicKey: keyPair.publicKey,
});
const contract = client.open(wallet);
return { contract, keyPair };
}
很好,之后我们将创建项目的主入口点 - src/app.ts
。
这里将使用刚刚创建的函数 openWallet
并调用我们的主函数 init
。
现在就到此为止。
import * as dotenv from "dotenv";
import { openWallet } from "./utils";
import { readdir } from "fs/promises";
dotenv.config();
async function init() {
const wallet = await openWallet(process.env.MNEMONIC!.split(" "), true);
}
void init();
最后,让我们在 src
目录中创建 delay.ts
文件,在该文件中,我们将创建一个函数来等待 seqno
的增加。
import { OpenedWallet } from "./utils";
export async function waitSeqno(seqno: number, wallet: OpenedWallet) {
for (let attempt = 0; attempt < 10; attempt++) {
await sleep(2000);
const seqnoAfter = await wallet.contract.getSeqno();
if (seqnoAfter == seqno + 1) break;
}
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
简单来说,seqno就是由钱包发送的外部交易的计数器。 Seqno用于预防重放攻击。当交易发送到钱包智能合约时,它将交易的seqno字段与其存储中的字段进行比较。如果它们匹配,交易被接受并且存储的seqno增加一。如果它们不匹配,交易被丢弃。这就是为什么我们需要在每次发送外部交易后稍等一会儿。
🖼 准备元数据
请注意,我们没有写"image"参数,稍后你会知道原因,请稍等!
在创建了集合的元数据文件之后,我们需要创建我们NFT的元数据。
NFT 规范
TON 上的大多数产品都支持此类元数据规范,以存储有关 NFT 收集的信息:
名称 | 解释 |
---|---|
name | 集合名称 |
description | 集合描述 |
image | 将显示为头像的图片链接。支持的链接格式:https、ipfs、TON Storage。 |
cover_image | 将显示为集合封面图片的图片链接。 |
social_links | 项目社交媒体配置文件的链接列表。使用不超过10个链接。 |
之后,你可以根据需要创建尽可能多的NFT项目及其元数据文件。
{
"name": "Ducks on TON",
"description": "This collection is created for showing an example of minting NFT collection on TON. You can support creator by buying one of this NFT.",
"social_links": ["https://t.me/DucksOnTON"]
}
现在让我们编写一些代码,将我们的元数据文件上传到IPFS。创建 metadata.ts
文件并添加所需的导入:
在创建了集合的元数据文件之后,我们需要创建我们NFT的元数据。
之后,我们需要创建一个函数,这个函数将把我们文件夹中的所有文件实际上传到IPFS:
名称 | 解释 |
---|---|
name | NFT名称。推荐长度:不超过15-30个字符 |
description | NFT描述。推荐长度:不超过500个字符 |
image | NFT图片链接。 |
attributes | NFT属性。属性列表,其中指定了trait_type (属性名称)和value (属性的简短描述)。 |
lottie | Lottie动画的json文件链接。如果指定,在NFT页面将播放来自此链接的Lottie动画。 |
content_url | 额外内容的链接。 |
content_type | 通过content_url链接添加的内容的类型。例如,视频/mp4文件。 |
太棒了!让我们回到之前的问题:为什么我们在元数据文件中留下了“image”字段为空?想象一下你想在你的集合中创建1000个NFT,并且你必须手动遍历每个项目并手动插入图片链接。 这真的很不方便,所以让我们编写一个函数来自动完成这个操作!
{
"name": "Duck #00",
"description": "What about a round of golf?",
"attributes": [{ "trait_type": "Awesomeness", "value": "Super cool" }]
}
这里我们首先读取指定文件夹中的所有文件:
上传元数据
遍历每个文件并获取其内容
import pinataSDK from "@pinata/sdk";
import { readdirSync } from "fs";
import { writeFile, readFile } from "fs/promises";
import path from "path";
之后,如果不是文件夹中的最后一个文件,我们将图像字段的值分配为 ipfs://{IpfsHash}/{index}.jpg
,否则为 ipfs://{imagesIpfsHash}/logo.jpg
并实际用新数据重写我们的文件。
export async function uploadFolderToIPFS(folderPath: string): Promise<string> {
const pinata = new pinataSDK({
pinataApiKey: process.env.PINATA_API_KEY,
pinataSecretApiKey: process.env.PINATA_API_SECRET,
});
const response = await pinata.pinFromFS(folderPath);
return response.IpfsHash;
}
太棒了!让我们回到之前的问题:为什么我们在元数据文件中留下了“image”字段为空?想象一下你想在你的集合中创建1000个NFT,并且你必须手动遍历每个项目并手动插入图片 链接。 这真的很不方便,所以让我们编写一个函数来自动完成这个操作!
export async function updateMetadataFiles(metadataFolderPath: string, imagesIpfsHash: string): Promise<void> {
const files = readdirSync(metadataFolderPath);
await Promise.all(files.map(async (filename, index) => {
const filePath = path.join(metadataFolderPath, filename)
const file = await readFile(filePath);
const metadata = JSON.parse(file.toString());
metadata.image =
index != files.length - 1
? `ipfs://${imagesIpfsHash}/${index}.jpg`
: `ipfs://${imagesIpfsHash}/logo.jpg`;
await writeFile(filePath, JSON.stringify(metadata));
}));
}
这里我们首先读取指定文件夹中的所有文件:
const files = readdirSync(metadataFolderPath);
遍历每个文件并获取其内容
const filePath = path.join(metadataFolderPath, filename)
const file = await readFile(filePath);
const metadata = JSON.parse(file.toString());
之后,如果不是文件夹中的最后一个文件,我们将图像字段的值分配为 ipfs://{IpfsHash}/{index}.jpg
,否则为 ipfs://{imagesIpfsHash}/logo.jpg
并实际用新数据重写我们的文件。
我们如何将链接到智能合约中存储的元数据文件?这个问题可以通过Token Data 标准得到完全回答。在某些情况下,仅仅提供所需的标志并以ASCII字符提供链接是不够的,这就是为什么我们考虑使用蛇形格式将我们的链接分成几个部分的选项。
import pinataSDK from "@pinata/sdk";
import { readdirSync } from "fs";
import { writeFile, readFile } from "fs/promises";
import path from "path";
export async function uploadFolderToIPFS(folderPath: string): Promise<string> {
const pinata = new pinataSDK({
pinataApiKey: process.env.PINATA_API_KEY,
pinataSecretApiKey: process.env.PINATA_API_SECRET,
});
const response = await pinata.pinFromFS(folderPath);
return response.IpfsHash;
}
export async function updateMetadataFiles(metadataFolderPath: string, imagesIpfsHash: string): Promise<void> {
const files = readdirSync(metadataFolderPath);
files.forEach(async (filename, index) => {
const filePath = path.join(metadataFolderPath, filename)
const file = await readFile(filePath);
const metadata = JSON.parse(file.toString());
metadata.image =
index != files.length - 1
? `ipfs://${imagesIpfsHash}/${index}.jpg`
: `ipfs://${imagesIpfsHash}/logo.jpg`;
await writeFile(filePath, JSON.stringify(metadata));
});
}
太好了,让我们在我们的 app.ts 文件中调用这些方法。 添加我们函数的导入:
import { updateMetadataFiles, uploadFolderToIPFS } from "./src/metadata";
保存元数据/图片文件夹路径变量并调用我们的函数上传元数据。
async function init() {
const metadataFolderPath = "./data/metadata/";
const imagesFolderPath = "./data/images/";
const wallet = await openWallet(process.env.MNEMONIC!.split(" "), true);
console.log("Started uploading images to IPFS...");
const imagesIpfsHash = await uploadFolderToIPFS(imagesFolderPath);
console.log(
`Successfully uploaded the pictures to ipfs: https://gateway.pinata.cloud/ipfs/${imagesIpfsHash}`
);
console.log("Started uploading metadata files to IPFS...");
await updateMetadataFiles(metadataFolderPath, imagesIpfsHash);
const metadataIpfsHash = await uploadFolderToIPFS(metadataFolderPath);
console.log(
`Successfully uploaded the metadata to ipfs: https://gateway.pinata.cloud/ipfs/${metadataIpfsHash}`
);
}
之后你可以运行 yarn start
并查看部署的元数据链接!
🚢 部署 NFT 集合
当我们的元数据已经准备好并且已经上传到IPFS时,我们可以开始部署我们的集合了!
我们将在 /contracts/NftCollection.ts
文件中创建一个文件,该文件将存储与我们的集合相关的所有逻辑。我们将从导入开始:
function bufferToChunks(buff: Buffer, chunkSize: number) {
const chunks: Buffer[] = [];
while (buff.byteLength > 0) {
chunks.push(buff.subarray(0, chunkSize));
buff = buff.subarray(chunkSize);
}
return chunks;
}
并声明一个类型,它将描述我们集合所需的初始化数据:
function makeSnakeCell(data: Buffer): Cell {
const chunks = bufferToChunks(data, 127);
if (chunks.length === 0) {
return beginCell().endCell();
}
if (chunks.length === 1) {
return beginCell().storeBuffer(chunks[0]).endCell();
}
let curCell = beginCell();
for (let i = chunks.length - 1; i >= 0; i--) {
const chunk = chunks[i];
curCell.storeBuffer(chunk);
if (i - 1 >= 0) {
const nextCell = beginCell();
nextCell.storeRef(curCell);
curCell = nextCell;
}
}
return curCell.endCell();
}
最后,我们需要创建一个函 数,使用这些函数将离线内容编码为cell:
export function encodeOffChainContent(content: string) {
let data = Buffer.from(content);
const offChainPrefix = Buffer.from([0x01]);
data = Buffer.concat([offChainPrefix, data]);
return makeSnakeCell(data);
}
🚢 部署 NFT 集合
在这段代码中,我们只是从集合智能合约的base64表示中读取cell。
剩下的只有我们集合初始化数据的cell了。
import {
Address,
Cell,
internal,
beginCell,
contractAddress,
StateInit,
SendMode,
} from "@ton/core";
import { encodeOffChainContent, OpenedWallet } from "../utils";