TON is not a new Layer 1 Blockchain by any means, but it has seen exponential growth in the first half of this year. Daily transactions are climbing upwards of 5+ million per day with no signs of stopping anytime soon.
If you’re not familiar with TON, it’s a proof of stake network that is heavily integrated with Telegram; most of the people building Telegram are also building TON. This level of integration poses a unique approach to consumer crypto by tapping into a well used social platform in Web3, allowing wallets, dApps, and more without leaving Telegram. Not only that, but TON also has an interesting multi-chain approach, where there is a master chain that handles critical information and then uses work-chains for different transactions. This model enables high performance and low fees. If you’re a developer looking to start building on TON, there’s no better way to start than to mint an NFT! In this guide, we’ll show you how to do just that by building an app that uploads the NFT assets securely to Pinata and mints an NFT on TON.
TON vs Ethereum NFTs
Before we dive into the main tutorial, it might help to explain how TON differs from Ethereum when it comes to NFTs. With Ethereum, or other EVM chains, you typically have a single smart contract with many NFTs. This has proven to cause unpredictable gas prices which trickle down to the end user. TON NFTs, on the other hand, have a contract for each NFT that are linked to a collection contract, which results in a more stable model for minting.
Since TON takes this approach, the developer tooling also looks very different. Instead of writing a smart contract that imports OpenZeppelin standards, deploying, testing, etc., we can just use TON’s libraries to create the collection on the fly. All we have to do is pass in the right metadata, then make additional calls to the master collection for each NFT we mint. That’s exactly what we’ll do in our app!
Setup
In order to start building the TON app we’re gonna need a few things first.
Pinata Account & API Key
Like with most other blockchains, we need a way to store the token URI of our NFTs offchain, and IPFS fits the bill. To use IPFS, we’ll need to make a free Pinata account here. Once we’ve created the account, we’ll want to make an API key that we can use to upload content from our TON app. You can do so by following these instructions, ensuring careful attention to copy down the API key somewhere safe. We’ll be using the longer JWT in particular.
Telegram
Since TON is so heavily integrated with Telegram I would recommend making an account, if you haven’t already, as well as downloading the desktop client as we build through the app. There will be several setup points that require interacting with bots through Telegram, and it’s probably the easiest way to walk through those parts.
TON Wallet
The next thing you’re gonna need is a TON wallet, as an EVM wallet like MetaMask isn’t going to work here. For this tutorial, we’ll use the official TON Wallet extension, as our code will be able to plug right into it. Once you’ve created the wallet and followed the steps to secure it, we’ll need to put it into testnet mode. To do this, you’ll want to click on the three dots in the top right, then shift+click on the version listed. This will trigger a modal for switching to testnet.
Now that we’re in testnet mode, we need to get testnet funds! You can do this by following this link which will open a new chat in Telegram with the faucet bot. Just follow the steps, provide your wallet address, and you’ll get some testnet funds!
TON API Key
Our app is going to use some of the TON libraries, which will send requests to an RPC node on toncenter.com and, because of that, we’ll need an API key to access it. You can get one by following this link which will, again, open a new Telegram chat with a bot that can give you the API key.
Next.js
Just for simplicity, we’ll be using Next.js as our stack, as we need a way to secure our Pinata API key. To kick off, run the following command in the terminal and select all the defaults.
npx create-next-app@latest ton-minting-tutorial
Once it’s done installing dependencies, run the following command to move into the project and install the TON library.
cd ton-minting-tutorial && npm install tonweb uuid
Now we can start building this thing out!
Building the App
As stated, we’ll be building an app that does the following:
Lets users connect their wallet
Create an NFT collection
Mint an NFT in the collection
Pretty simple! To start, let’s handle our API keys by creating an .env.local
file and fill in the variables below:
PINATA_JWT= // Pinata JWT from when we made our API key earlier
NEXT_PUBLIC_TON_API_KEY= // TON API key we made through the bot
After we have those keys in place, you will want to make a new file in the root of the project called constants.config.ts
and put in the following code.
/**
* Change url to access the network
* <https://testnet.toncenter.com/api/v2/jsonRPC> — testnet
* <https://toncenter.com/api/v2/jsonRPC> — mainnet
*
*/
export const TEST_NETWORK = true;
export const NETWORK = TEST_NETWORK
? "<https://testnet.toncenter.com/api/v2/jsonRPC>"
: "<https://toncenter.com/api/v2/jsonRPC>";
export const EXPLORER_URL = TEST_NETWORK
? "<https://testnet.tonscan.org>"
: "<https://tonscan.org>";
/**
* Create your API_KEY in your Telegram account:
* @tontestnetapibot — for testnet
* @tonapibot — for mainnet
*
*/
export const API_KEY = `${process.env.NEXT_PUBLIC_TON_API_KEY}`;
One other small thing we will need to do is make a custom type exception for window.ton
, just like we do for Ethereum. Make a file called new-types.d.ts
in the root of your project and put in the following contents.
interface Window {
ton: any;
tonProtocolVersion: any;
}
To make sure the new types are included in our project build settings you will want to add the filename to the array in tsconfig.json
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "new-types.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Now, we can navigate to our main app page app/page.tsx
, clear out the boiler plate, and import some dependencies.
"use client";
import { useState } from "react";
import * as constants from "../constants.config";
import TonWeb from "tonweb";
export default function Home() {
return (
<div className="flex flex-col justify-center items-center min-h-screen gap-4">
</div>
);
}
To start using the TonWeb library, we’ll import a constants, as well as create a new TonWeb
instance.
"use client";
import { useState } from "react";
import * as constants from "../constants.config";
import TonWeb from "tonweb";
const { NftCollection, NftItem } = TonWeb.token.nft;
const tonweb = new TonWeb(
new TonWeb.HttpProvider(constants.NETWORK, {
apiKey: constants.API_KEY,
}),
);
export default function Home() {
return (
<div className="flex flex-col justify-center items-center min-h-screen gap-4">
</div>
);
}
With that completed, we can create a function that allows users to connect their wallet with us by adding the following code:
"use client";
import { useState } from "react";
import * as constants from "../constants.config";
import TonWeb from "tonweb";
const { NftCollection, NftItem } = TonWeb.token.nft;
const tonweb = new TonWeb(
new TonWeb.HttpProvider(constants.NETWORK, {
apiKey: constants.API_KEY,
}),
);
export default function Home() {
const [walletAddress, setWalletAddress]: any = useState("");
const connectWallet = async () => {
try {
if (window.tonProtocolVersion || window.tonProtocolVersion > 1) {
if (window.ton.isTonWallet) {
console.log("TON Wallet Extension found!");
}
const provider = window.ton;
const accounts = await provider.send("ton_requestWallets");
const walletAddress = new TonWeb.utils.Address(accounts[0].address);
console.log("Connected accounts:", accounts);
console.log(
"Connected wallet address:",
walletAddress.toString(true, true, true),
);
setWalletAddress(walletAddress);
} else {
alert("Please update your TON Wallet Extension 💎");
location.href =
"<https://chrome.google.com/webstore/detail/ton-wallet/nphplpgoakhhjchkkhmiggakijnkhfnd>";
}
} catch (e) {
console.error(e);
}
};
return (
<div className="flex flex-col justify-center items-center min-h-screen gap-4">
</div>
);
}
In this code, we do something similar to Ethereum where we get the window.ton
object, check if there is a wallet connected and, if not, prompt the user to connect. Once they connect, we set the account to our useState
for later. If the user doesn’t have a wallet, or if it’s outdated, we can give them a link to the extension.
Once we have their wallet connected, we can start looking into creating the NFT collection. In order for this to work on TON, we actually need contract metadata, just like we do for individual NFTs, otherwise we won’t really see much on marketplaces or explorers. Thankfully, we can just as easily store this on IPFS as we can with the NFT itself! To handle uploads in a secure fashion, we’re going to take a “signed JWT” approach where we use a Next.js API route to generate temporary keys that we can use for uploads on the client side.
To make this little API, we’ll make a route in our files with the path app/api/key/route.ts
. Inside that route.ts
file, we want to go ahead and put in the following code.
import { NextRequest, NextResponse } from "next/server";
const { v4: uuidv4 } = require("uuid");
const pinataJWT = process.env.PINATA_JWT;
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest, res: NextResponse) {
try {
const uuid = uuidv4();
const body = JSON.stringify({
keyName: uuid.toString(),
permissions: {
endpoints: {
pinning: {
pinFileToIPFS: true,
pinJSONToIPFS: true,
},
},
},
maxUses: 1,
});
const keyRes = await fetch(
"<https://api.pinata.cloud/users/generateApiKey>",
{
method: "POST",
body: body,
headers: {
accept: "application/json",
"content-type": "application/json",
authorization: `Bearer ${pinataJWT}`,
},
},
);
const keyResJson = await keyRes.json();
const keyData = {
pinata_api_key: keyResJson.pinata_api_key,
JWT: keyResJson.JWT,
};
return NextResponse.json(keyData, { status: 200 });
} catch (error) {
console.log(error);
return NextResponse.json(
{ text: "Error creating API Key:" },
{ status: 500 },
);
}
}
With this method, we can create a temporary key that can only be used once and is limited to just pinFileToIPFS
and pinJSONToIPFS
. Now that we have our API endpoint ready, we can make the upload functions to handle files - like images and JSON data - for NFT metadata! Make a new folder in the root of your project called utils/uploads.ts
and put in the following code.
export async function uploadFile(selectedFile: File | undefined) {
if (!selectedFile) {
console.log("no file provided!");
return;
}
try {
const tempKey = await fetch("/api/key", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const keyData = await tempKey.json();
const formData = new FormData();
formData.append("file", selectedFile);
const metadata = JSON.stringify({
name: `${selectedFile.name}`,
});
formData.append("pinataMetadata", metadata);
const options = JSON.stringify({
cidVersion: 1,
});
formData.append("pinataOptions", options);
const uploadRes = await fetch(
"<https://api.pinata.cloud/pinning/pinFileToIPFS>",
{
method: "POST",
headers: {
Authorization: `Bearer ${keyData.JWT}`,
},
body: formData,
},
);
console.log({ uploadResStatus: uploadRes.status });
if (uploadRes.status != 200) {
throw Error;
}
const uploadResJson = await uploadRes.json();
const cid = uploadResJson.IpfsHash;
console.log(cid);
return cid;
} catch (error) {
console.log("Error uploading file:", error);
}
}
export async function uploadJson(content: any) {
try {
const data = JSON.stringify({
pinataContent: content,
pinataOptions: {
cidVersion: 1,
},
});
const tempKey = await fetch("/api/key", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const keyData = await tempKey.json();
const uploadRes = await fetch(
"<https://api.pinata.cloud/pinning/pinJSONToIPFS>",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${keyData.JWT}`,
},
body: data,
},
);
const uploadResJson = await uploadRes.json();
const cid = uploadResJson.IpfsHash;
console.log(cid);
return cid;
} catch (error) {
console.log("Error uploading file:", error);
}
}
These two functions are pretty straight forward. For each one, we request a temporary key, then used it in our upload. uploadFile
takes a file like an image and uploadJson
takes any kind of object - both return a CID we can use for the NFTs.
With the upload step complete, we can now create our other two TON functions in app/page.tsx
. Like we mentioned earlier, we need the user to create a collection first, and once it’s deployed, we can mint NFTs to that collection. The collection metadata has several requirements such as a name, description, external link, image, and royalty points. Let’s add that code now to page.tsx
.
"use client";
import { useState } from "react";
import { uploadFile, uploadJson } from "@/utils/uploads";
import * as constants from "../constants.config";
import TonWeb from "tonweb";
const { NftCollection, NftItem } = TonWeb.token.nft;
const tonweb = new TonWeb(
new TonWeb.HttpProvider(constants.NETWORK, {
apiKey: constants.API_KEY,
}),
);
export default function Home() {
const [walletAddress, setWalletAddress]: any = useState("");
const [collectionName, setCollectionName] = useState<string>();
const [collectionDescription, setCollectionDescription] = useState<string>();
const [collectionExternalUrl, setCollectionExternalUrl] = useState<string>();
const [collectionRoyalty, setCollectionRoyalty] = useState<number>(5);
const [collectionFile, setCollectionFile] = useState<File | undefined>();
const [collectionAddress, setCollectionAddress]: any = useState(null);
const [nftCollection, setNftCollection]: any = useState(null);
const [complete, setComplete] = useState(false);
const connectWallet = async () => {
// previous code...
};
const deployNftCollection = async () => {
const provider = window.ton;
const collectionImageCid = await uploadFile(collectionFile);
const collectionData = {
name: collectionName,
description: collectionDescription,
external_link: collectionExternalUrl,
image: `ipfs://${collectionImageCid}`,
seller_fee_basis_points: 100,
fee_recipient: walletAddress.toString(true, true, true),
};
const collectionUri = await uploadJson(collectionData);
const nftCollection = new NftCollection(tonweb.provider, {
ownerAddress: walletAddress, // owner of the collection
royalty: collectionRoyalty / 100, // royalty in %
royaltyAddress: walletAddress, // address to receive the royalties
collectionContentUri: `ipfs://${collectionUri}`, // url to the collection content
nftItemContentBaseUri: "", // url to the nft item content
nftItemCodeHex: NftItem.codeHex, // format of the nft item
});
console.log("Collection data:", nftCollection);
const nftCollectionAddress = await nftCollection.getAddress();
console.log(
"Collection address (changes with provided data):",
nftCollectionAddress.toString(true, true, true),
);
const stateInit = (await nftCollection.createStateInit()).stateInit;
const stateInitBoc = await stateInit.toBoc(false);
const stateInitBase64 = TonWeb.utils.bytesToBase64(stateInitBoc);
provider
.send("ton_sendTransaction", [
{
to: nftCollectionAddress.toString(true, true, true),
value: TonWeb.utils.toNano((0.05).toString()).toString(), // 0.05 TON to cover the gas
stateInit: stateInitBase64,
dataType: "boc",
},
])
.then(async (res: any) => {
if (res) {
console.log("Transaction successful");
setCollectionAddress(nftCollectionAddress);
setNftCollection(nftCollection);
} else {
console.log("Wallet didn't approved minting transaction");
}
})
.catch((err: any) => {
console.error(err);
});
};
return (
<div className="flex flex-col justify-center items-center min-h-screen gap-4">
</div>
);
}
At the top of the file, we import our upload functions and declare some new states. In our deployCollection
function, we’ll first connect to the TON provider, then we’ll upload the image provided for the collection. With that CID, we’ll construct the metadata object for the collection, which takes in most of the parameters we’ll ask for in the front end. One nice thing to note is that we can use the ipfs://
protocol url, as TON made the effort to support it in their ecosystem to help ensure persistent content, which you can read more about here.
Once we have our collectionData
ready we can upload it with uploadJson
, which will give us a CID that will act as the collectionUri
. Now, we can actually create the collection using new NftCollection
from the TON library, where it is then passed through a state initialization, BOC (Bags of Cells) binary object, base6, which is finally passed into provider.send
where we send the BOC data and prompt the user to sign the transaction. If all goes well, then we can set the nftCollectionAddress
and nftCollection
object for minting the NFT.
Minting the NFT to the collection looks pretty similar. Let’s update our page.tsx
file.
"use client";
import { useState } from "react";
import { uploadFile, uploadJson } from "@/utils/uploads";
import * as constants from "../constants.config";
import TonWeb from "tonweb";
const { NftCollection, NftItem } = TonWeb.token.nft;
const tonweb = new TonWeb(
new TonWeb.HttpProvider(constants.NETWORK, {
apiKey: constants.API_KEY,
}),
);
export default function Home() {
const [walletAddress, setWalletAddress]: any = useState("");
const [collectionName, setCollectionName] = useState<string>();
const [collectionDescription, setCollectionDescription] = useState<string>();
const [collectionExternalUrl, setCollectionExternalUrl] = useState<string>();
const [collectionRoyalty, setCollectionRoyalty] = useState<number>(5);
const [collectionFile, setCollectionFile] = useState<File | undefined>();
const [collectionAddress, setCollectionAddress]: any = useState(null);
const [nftCollection, setNftCollection]: any = useState(null);
const [name, setName] = useState<string>();
const [description, setDescription] = useState<string>();
const [externalUrl, setExternalUrl] = useState<string>();
const [file, setFile] = useState<File | undefined>();
const [complete, setComplete] = useState(false);
const connectWallet = async () => {
// previous code...
};
const deployNftCollection = async () => {
// previous code..
};
const deployNftItem = async () => {
const provider = window.ton;
const amount = TonWeb.utils.toNano((0.05).toString());
const nftImageCid = await uploadFile(file);
const nftData = {
name: name,
description: description,
image: `ipfs://${nftImageCid}`,
external_link: externalUrl,
};
const nftUri = await uploadJson(nftData);
const body = await nftCollection.createMintBody({
amount: amount,
itemIndex: "0", // Typically you will want to fetch the existing colleciton to see the next token id
itemContentUri: `ipfs://${nftUri}`,
itemOwnerAddress: walletAddress,
});
const bodyBoc = await body.toBoc(false);
const bodyBase64 = TonWeb.utils.bytesToBase64(bodyBoc);
provider
.send("ton_sendTransaction", [
{
to: collectionAddress.toString(true, true, true),
value: amount.toString(),
data: bodyBase64,
dataType: "boc",
},
])
.then((res: any) => {
if (res) {
setComplete(true);
console.log("Transaction successful");
} else {
console.log("Wallet didn't approved minting transaction");
}
})
.catch((err: any) => {
console.log(err);
});
};
return (
<div className="flex flex-col justify-center items-center min-h-screen gap-4">
</div>
);
}
In this function, we follow the same pattern where we upload an image, use the image CID in the metadata, upload the metadata, which then leaves us with a nftUri
. We can use the nftCollection
object we made earlier to pass in that nftUri
the same way we did before with the IPFS protocol url: ipfs://
. Additionally, we can pass in things like the itemIndex
which works like a token ID. Generally, you would want to use TonWeb to fetch the latest index and increment it, since it doesn’t happen automatically like most Solidity smart contracts. After we have our body
complete, we can compile it to BOC, the bytes to base64, and finally send the transaction to the collectionAddress
.
With all of our functions ready to go, we can add in our JSX forms and fields.
"use client";
import { useState } from "react";
import { uploadFile, uploadJson } from "@/utils/uploads";
import * as constants from "../constants.config";
import TonWeb from "tonweb";
const { NftCollection, NftItem } = TonWeb.token.nft;
const tonweb = new TonWeb(
new TonWeb.HttpProvider(constants.NETWORK, {
apiKey: constants.API_KEY,
}),
);
export default function Home() {
// all our useState
const connectWallet = async () => {
// previous code
};
const deployNftCollection = async () => {
// previous code
};
const deployNftItem = async () => {
// previous code
};
return (
<div className="flex flex-col justify-center items-center min-h-screen gap-4">
{!walletAddress && !complete && (
<button
className="border border-black rounded-md p-2"
onClick={connectWallet}
>
Connect Wallet
</button>
)}
{walletAddress && !collectionAddress && !complete && (
<>
<h2 className="text-3xl font-bold">Create NFT Collection</h2>
<div>Connected: {walletAddress.toString(true, true, true)}</div>
<input
className="border border-black rounded-md p-2"
placeholder="Collection Name"
type="text"
value={collectionName}
onChange={(e) => setCollectionName(e.target.value)}
/>
<input
className="border border-black rounded-md p-2"
placeholder="Collection Description"
type="text"
value={collectionDescription}
onChange={(e) => setCollectionDescription(e.target.value)}
/>
<input
className="border border-black rounded-md p-2"
placeholder="<https://pinata.cloud>"
type="text"
value={collectionExternalUrl}
onChange={(e) => setCollectionExternalUrl(e.target.value)}
/>
<input
className="border border-black rounded-md p-2"
placeholder="royalty percentage (e.g. 5 for 5%)"
type="number"
value={collectionRoyalty}
onChange={(e: any) => setCollectionRoyalty(e.target.value)}
/>
<input
className="border border-black rounded-md p-2"
type="file"
onChange={(e) =>
setCollectionFile(
e.target.files && e.target.files.length > 0
? e.target.files[0]
: undefined,
)
}
/>
<button
onClick={deployNftCollection}
className="border border-black rounded-md p-2"
>
Create Collection
</button>
</>
)}
{walletAddress && collectionAddress && !complete && (
<>
<h2 className="text-3xl font-bold">Mint an NFT</h2>
<div>Connected: {walletAddress.toString(true, true, true)}</div>
<div>
Collection Address: {collectionAddress.toString(true, true, true)}
</div>
<input
className="border border-black rounded-md p-2"
placeholder="Name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
className="border border-black rounded-md p-2"
placeholder="Description"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<input
className="border border-black rounded-md p-2"
placeholder="<https://pinata.cloud>"
type="text"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
/>
<input
className="border border-black rounded-md p-2"
type="file"
onChange={(e) =>
setFile(
e.target.files && e.target.files.length > 0
? e.target.files[0]
: undefined,
)
}
/>
<button
onClick={deployNftItem}
className="border border-black rounded-md p-2"
>
Mint NFT
</button>
</>
)}{" "}
{walletAddress && collectionAddress && complete && (
<>
<h2>Mint Complete! 🎉</h2>
<a
href={`https://testnet.getgems.io/collection/${collectionAddress}`}
className="underline font-bold"
>
View NFT
</a>
</>
)}
</div>
);
}
There’s a lot here, so let’s break it down a bit. In the beginning, we check if there is a connected address and, if not, show the button for the user to connect with. Then, we show the “create collection” section of our app. Once the collection is deployed, we show the mint section. Both of these sections use the state we declared earlier to fill in the fields of our collection and NFT metadata. Once the user has minted the NFT, we can show them a completion modal where they can view the NFT on getgems.io!
To see the full code for this tutorial you can check out the repo here!
Wrapping Up
While TON is a shift from what most Web3 developers are used to, there is a plethora of material to learn from and build whatever you want! Having a blockchain built alongside a social platform is truly powerful. Building on it means having a whole different layer of distribution, and Pinata is here to help you scale and keep your content persistent.
Happy Pinning!
![Pinata logo](/_next/image?url=https%3A%2F%2Fstorage.googleapis.com%2Fpapyrus_images%2Fd21a2d8fbde31bd3282d8b5a7919820d.png&w=384&q=75)