Step by step NFT collection minting
👋 Introduction
Non-fungible tokens (NFTs) have become one of the hottest topics in the world of digital art and collectibles. NFTs are unique digital assets that use blockchain technology to verify ownership and authenticity. They have opened up new possibilities for creators and collectors to monetize and trade digital art, music, videos, and other forms of digital content. In recent years, the NFT market has exploded, with some high-profile sales reaching millions of dollars. In this article, we will build an NFT collection on TON step by step.
This is the beautiful collection of ducks you will create by the end of this tutorial:
🦄 What you will learn
- You will mint NFT collection on TON.
- You will understand how NFTs on TON works.
- You will put NFT on sale.
- You will upload metadata to pinata.cloud.
💡 Prerequisites
You must already have a testnet wallet with at least 2 TON in it. You can get testnet coins from @testgiver_ton_bot.
To open the testnet network on Tonkeeper, go to settings and click 5 times on the Tonkeeper logo located at the bottom. Then choose "testnet" instead of "mainnet."
We will use Pinata as our IPFS storage system, so you also need to create an account on pinata.cloud and get api_key & api_secreat. Official Pinata documentation tutorial can help with that. Once you have these API tokens, I’ll be waiting for you here!
💎 What is it NFT on TON?
Before starting the main part of our tutorial, we need to understand how NFTs work on TON in general terms. Unexpectedly, we will start with an explanation of how NFTs work on Ethereum (ETH), to understand the uniqueness of NFT implementation on TON compared to other blockchains in the industry.
NFT implementation on ETH
The implementation of the NFT in ETH is extremely simple - there is 1 main contract of the collection, which stores a simple hashmap, which in turn stores the data of the NFT from this collection. All requests related to this collection (if any user wants to transfer the NFT, put it up for sale, etc.) are sent specifically to this single contract of the collection.
Problems that can occur with such implementation in TON
The problems of such an implementation in the context of TON are perfectly described by the NFT standard in TON:
-
Unpredictable gas consumption. In TON, gas consumption for dictionary operations depends on exact set of keys. Also, TON is an asynchronous blockchain. This means that if you send a message to a smart contract, then you do not know how many messages from other users will reach the smart contract before your message. Thus, you do not know what the size of the dictionary will be at the moment when your message reaches the smart contract. This is OK with a simple wallet -> NFT smart contract interaction, but not acceptable with smart contract chains, e.g. wallet -> NFT smart contract -> auction -> NFT smart contract. If we cannot predict gas consumption, then a situation may occur like that the owner has changed on the NFT smart contract, but there were no enough Toncoins for the auction operation. Using smart contracts without dictionaries gives deterministic gas consumption.
-
Does not scale (becomes a bottleneck). Scaling in TON is based on the concept of sharding, i.e. automatic partitioning of the network into shardchains under load. The single big smart contract of the popular NFT contradicts this concept. In this case, many transactions will refer to one single smart contract. The TON architecture provides for sharded smart contracts(see whitepaper), but at the moment they are not implemented.
TL;DR ETH solution it's not scalable and not suitable for asynchronous blockchain like TON.
TON NFT implementation
On TON, we have on master contract - smart-contract of our collection, that store it's metadata and address of it's owner and the main thing - that if we want to create("mint") new NFT Item - we just need to send message to this collection contract. This collection contract will then deploy a new NFT item contract for us, using the data we provide.
You can check NFT processing on TON article or read NFT standard if you want to dive deeper into this topic
⚙ Setup development environment
Let's start by creating an empty project:
- Create new folder
mkdir MintyTON
- Open this folder
cd MintyTON
- Init our project
yarn init -y
- Install typescript
yarn add typescript @types/node -D
- Copy this config to 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/**/*"]
}
- Add script to build & start our app to package.json
"scripts": {
"start": "tsc --skipLibCheck && node dist/app.js"
},
- Install required libraries
yarn add @pinata/sdk dotenv ton ton-core ton-crypto
- Create
.env
file and add your own data based on this template
PINATA_API_KEY=your_api_key
PINATA_API_SECRET=your_secret_api_key
MNEMONIC=word1 word2 word3 word4
TONCENTER_API_KEY=aslfjaskdfjasasfas
You can get toncenter api key from @tonapibot and choose mainnet or testnet. In MNEMONIC
variable store 24 words of collection owner wallet seed phrase.
Great! Now we are ready to start writing code for our project.
Write helper functions
Firstly let's create function in src/utils.ts
, that will open our wallet by mnemonic and return publicKey/secretKey of it.
We get a pair of keys based on 24 words(seed phrase):
import { KeyPair, mnemonicToPrivateKey } from "ton-crypto";
import {
beginCell,
Cell,
OpenedContract,
TonClient,
WalletContractV4,
} from "ton";
export type OpenedWallet = {
contract: OpenedContract<WalletContractV4>;
keyPair: KeyPair;
};
export async function openWallet(mnemonic: string[], testnet: boolean) {
const keyPair = await mnemonicToPrivateKey(mnemonic);
}
Create a class instance to interact with 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,
});
And finally open our wallet:
const wallet = WalletContractV4.create({
workchain: 0,
publicKey: keyPair.publicKey,
});
const contract = client.open(wallet);
return { contract, keyPair };
Nice, after that we will create main entrypoint for our project - app.ts
.
Here will use just created function openWallet
and call our main function init
.
Thats enough for now.
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();
And by the end, let's create delay.ts
file, in which we will create function to wait until seqno
increases.
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));
}
In simply words, seqno it's just a counter of outgoing transactions sent by wallet. Seqno used to prevent Replay Attacks. When a transaction is sent to a wallet smart contract, it compares the seqno field of the transaction with the one inside its storage. If they match, it's accepted and the stored seqno is incremented by one. If they don't match, the transaction is discarded. That's why we will need to wait a bit, after every outgoing transaction.
🖼 Prepare metadata
Metadata - is just a simple information that will describe our NFT or collection. For example it's name, it's description, etc.
Firstly, we need to store images of our NFT's in /data/images
with name 0.png
, 1.png
, ... for photo of items, and logo.png
for avatar of our collection. You can easily download pack with ducks images or put your images into that folder. And also we will store all our metadata files in /data/metadata/
folder.
NFT specifications
Most products on TON supports such metatadata specifications to store information about NFT collection:
Name | Explanation |
---|---|
name | Collection name |
description | Collection description |
image | Link to the image, that will be displayed as the avatar. Supported link formats: https, ipfs, TON Storage. |
cover_image | Link to the image, that will be displayed as the collection’s cover image. |
social_links | List of links to the project’s social media profiles. Use no more than 10 links. |
Based on this info, let's create our own metadata file collection.json
, that will describe the metadata of our collection!
{
"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"]
}
Note that we didn't write the "image" parameter, you will know why a bit later, just wait!
After creation of collection metadata file we need to create metadata of our NFT's
Specifications of NFT Item metadata:
Name | Explanation |
---|---|
name | NFT name. Recommended length: No more than 15-30 characters |
description | NFT description. Recommended length: Up to 500 characters |
image | Link to the image of NFT. |
attributes | NFT attributes. A list of attributes, where a trait_type (attribute name) and value (a short description of the attribution) is specified. |
lottie | Link to the json file with Lottie animation. If specified, the Lottie animation from this link will be played on page with the NFT. |
content_url | Link to additional content. |
content_type | The type of content added through the content_url link. For example, a video/mp4 file. |
{
"name": "Duck #00",
"description": "What about a round of golf?",
"attributes": [{ "trait_type": "Awesomeness", "value": "Super cool" }]
}
After that, you can create as many files of NFT item with their metadata as you want.
Upload metadata
Now let's write some code, that will upload our metadata files to IPFS. Create metadata.ts
file and add all needed imports:
import pinataSDK from "@pinata/sdk";
import { readdirSync } from "fs";
import { writeFile, readFile } from "fs/promises";
import path from "path";
After that, we need to create function, that will actually upload all files from our folder to IPFS:
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;
}
Excellent! Let's return to the question at hand: why did we leave the "image" field in the metadata files empty? Imagine a situation where you want to create 1000 NFTs in your collection and, accordingly, you have to manually go through each item and manually insert a link to your picture. This is really inconvenient and wrong, so let's write a function that will do this automatically!
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));
}));
}
Here we firstly read all of the files in specified folder:
const files = readdirSync(metadataFolderPath);
Iterate over each file and get its content
const filePath = path.join(metadataFolderPath, filename)
const file = await readFile(filePath);
const metadata = JSON.parse(file.toString());
After that, we assign the value ipfs://{IpfsHash}/{index}.jpg
to the image field if it's not last file in the folder, otherwise ipfs://{imagesIpfsHash}/logo.jpg
and actually rewrite our file with new data.
Full code of metadata.ts:
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));
});
}
Great, let's call this methods in our app.ts file. Add imports of our functions:
import { updateMetadataFiles, uploadFolderToIPFS } from "./metadata";
Save variables with path to the metadata/images folder and call our functions to upload 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}`
);
}
After that you can run yarn start
and see link to your deployed metadata!
Encode offchain content
How will link to our metadata files stored in smart contract? This question can be fully answered by the Token Data Standart. In some cases, it will not be enough to simply provide the desired flag and provide the link as ASCII characters, which is why let's consider an option in which it will be necessary to split our link into several parts using the snake format.
Firstly create function, that will convert our buffer into chunks:
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;
}
And create function, that will bind all the chunks into 1 snake-cell:
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();
}
Finally, we need to create function that will encode offchain content into cell using this functions:
export function encodeOffChainContent(content: string) {
let data = Buffer.from(content);
const offChainPrefix = Buffer.from([0x01]);
data = Buffer.concat([offChainPrefix, data]);
return makeSnakeCell(data);
}
🚢 Deploy NFT Collection
When our metadata is ready and already uploaded to IPFS, we can start with deploying our collection!
We will create file, that will store all logic related to our collection in /contracts/NftCollection.ts
file. As always will start with imports:
import {
Address,
Cell,
internal,
beginCell,
contractAddress,
StateInit,
SendMode,
} from "ton-core";
import { encodeOffChainContent, OpenedWallet } from "../utils";