Cover photo

How To Mint NFTs With Foundry, Viem, And Pinata

Combine Foundry, Viem, and Pinata to create, test, and deploy an ERC721 minting contract, and build an app to upload content to IPFS and mint NFTs.

Creating smart contracts and interacting with them through a website is nothing new, but the frameworks that are used change constantly. When it comes to writing and testing smart contracts, many developer agree Foundry is at the top, and for good reasons. Foundry is built in Rust so its compiler and test builds are blazing fast. All the tests and scripts are written in Solidity so there’s no context switching in your code. As far as interacting with smart contracts through a Typescript environment, nothing beats Viem. It’s a clean and simplistic library for building apps on Ethereum-compatible blockchains. However if you ever have to mint NFTs with your app, the chances are you need a place to store the images and metadata. No better place for that then IPFS with Pinata!

Today we’ll show you how all three work together. With Foundry we’ll write, test, and deploy a simple ERC721 minting contract. With Viem and Pinata we’ll build an app that lets people upload the content to IPFS, then turn it into an NFT with Viem and our previously deployed contract. With the core concepts in this tutorial you can really build any kind of dApp that utilizes smart contracts and IPFS. Let’s dig in!

Setup

Before we get started you’ll need just a few things to kick off.

Pinata Account

The first thing you’ll need is a Pinata account, which you can start for free by signing up here. For this tutorial you can use use the default free account, and if you happen to take your project further you can scale with one of our paid plans! Once you create your account, all you need to do is create an API key with these instructions. That’s it!

Wallet on Base Sepolia

We’ll be deploying our contract to the Base Sepolia testnet, and if you don’t already you’ll need a crypto wallet. I would highly recommend the Coinbase Wallet as its pretty easy to use and will have lots of documentation on getting things like the private key and getting testnet funds. You can learn more about it here.

Development Environment

You will probably need at least some coding experience and command line experience to run through this, as well as having things like Node.js installed and a text editor handy!

Smart Contract

As stated before we’ll be using Foundry to write our smart contract. In order to use Foundry you’ll need to install it first, which you can do by following the instructions here. Once installed you can make sure it’s working by running forge --version. Then we can use it to initialize our project.. Then we can use it to initialize our project.

forge init my_nft

Once the project is put together, cd into it and build it to make sure its working properly.

cd my_nft && forge build

If it works you should see the output:

[⠊] Compiling...
[⠊] Compiling 27 files with Solc 0.8.23
[⠢] Solc 0.8.23 finished in 1.00s
Compiler run successful!

Then we’re going to install the OpenZeppelin library for our smart contracts, so run the following command:

forge install OpenZeppelin/openzeppelin-contracts

If we take a look at the project structure you’ll notice a few things.

.
├── foundry.toml
├── lib
│  └── forge-std
├── README.md
├── script
│  └── Counter.s.sol
├── src
│  └── Counter.sol
└── test
   └── Counter.t.sol

At the top we have a foundry.toml file that will be the config for the project. Outside of that we have a lib folder were dependencies are stores, src where the contracts live, script for automation we can run, and test for our contract testing.

Now let’s go into the project structure and delete the contents of src, script, and test. In order for our OpenZeppelin contracts to have a more natural import method, we’ll do one short command to export a remapping in our directory to help locate the library.

forge remappings > remappings.txt

This will allow us to import the contracts at the top of our file with the more standard import "@openzeppelin/contracts/token/ERC721/ERC721.sol" vs doing a relative path.

For actually writing the contract I love to start on the OpenZeppelin Contract Wizard, where we can just pick some default options. For this contract I’ll select ERC721, put in the name “MyNFT” and the symbol “MYNFT”, leave base URI blank, then the following options.

  • Mintable

    • Auto Increment IDs

  • Burnable

  • Pausable

  • URI Storage

By all means do careful research and testing for your specific use case when you’re looking to deploy a smart contract to mainnet. In the end I’m going to paste the following code into a new file in our src folder called MyNFT.sol.

// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Pausable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";

contract MyNFT is ERC721, ERC721URIStorage, ERC721Pausable, Ownable, ERC721Burnable {
    uint256 private _nextTokenId;

    constructor()
        ERC721("MyNFT", "MYNFT")
        Ownable(msg.sender)
    {}

    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

    function safeMint(address to, string memory uri) public {
        uint256 tokenId = _nextTokenId++;
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
    }

    // The following functions are overrides required by Solidity.

    function _update(address to, uint256 tokenId, address auth)
        internal
        override(ERC721, ERC721Pausable)
        returns (address)
    {
        return super._update(to, tokenId, auth);
    }

    function tokenURI(uint256 tokenId)
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

I made just a few small changes that you may want to note:

  • Removed the argument in the Constructor and just used msg.sender as the owner, i.e. whoever deploys the contract will be the owner.

  • Removed the restriction of onlyOwner on the safeMint function. This allows people to mint their own NFT but of course adjust based on your needs.

After we have saved this file we can make sure it still builds with forge build

With the smart contract done we can add a small example test in our test folder; add the file MyNFT.t.sol with the following contents.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import {Test, console} from "forge-std/Test.sol";
import {MyNFT} from "../src/MyNFT.sol";

contract MyNFTTest is Test {
    MyNFT instance;
    address owner;
    address recipient;

    function setUp() public {
        instance = new MyNFT();
        owner = address(this);
        recipient = makeAddr("recipient");
    }

    function testSafeMint() public {
        string memory tokenURI = "ipfs://CID";
        instance.safeMint(recipient, tokenURI);
        assertEq(instance.ownerOf(0), recipient);
        assertEq(instance.tokenURI(0), tokenURI);
    }

}

In here we import the contract we just wrote and declare a test. In the test we make some variables for the contract, the owner, and a recipient. With the setUp() function we can create a new instance of the contract, declare the owner, and create a random address for recipient. Finally we make a testSafeMint() function where we call the contract instance with the method safeMint and passing in the arguments of recipient and tokenURI. After that we can use assertEq to make sure the owner of NFT 0 is the recipient, and that the tokenURI of 0 is what we passed in. Now let’s run the test with the following command.

forge test -vvv --gas-report

This will run our tests, give us verbose errors if there are any, and give us a nice report of how much gas is used for the functions used!

[PASS] testSafeMint() (gas: 136031)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.07ms (102.67µs CPU time)
| src/MyNFT.sol:MyNFT contract |                 |        |        |        |         |
|------------------------------|-----------------|--------|--------|--------|---------|
| Deployment Cost              | Deployment Size |        |        |        |         |
| 1335108                      | 6238            |        |        |        |         |
| Function Name                | min             | avg    | median | max    | # calls |
| ownerOf                      | 664             | 664    | 664    | 664    | 1       |
| safeMint                     | 120397          | 120397 | 120397 | 120397 | 1       |
| tokenURI                     | 1827            | 1827   | 1827   | 1827   | 1       |

Now there’s only one thing left to do which is deploy our smart contract. In this tutorial we’ll be deploying to Base Sepolia but you can use any chain you like based on the RPC URL you provide. To deploy from the command line we would use a command like this

forge create src/MyNFT.sol:MyNFT \
--rpc-url https://sepolia.base.org \
--private-key <PRIVATE_KEY>

In the first line we declare what we are deploying, starting with the path to the smart contract and then the name of the contract we declare in the file. Then we declare the --rpc-url which in our case is the free to use Base Sepolia RPC, and then our --private-key from the wallet which should already have some test Eth on the Base Sepolia network. It should be important to note that you usually want to use a wallet that is setup with just test funds and no mainnet crypto, and you usually don’t want to past the keys in plain text like this. Foundry provides a tool called cast that has some great management which you can check out here. Once successfully deployed we should see an address to which the contract was deployed to.

Deployer: 0x971e6c57d9824a803f56f1b0d5D998160fe196a9
Deployed to: 0x521587f3Cc78651703F22423BBD4De6Cd9ad8E09
Transaction hash: 0x063834efe214f4199b1ad7181ce8c5ced3e15d271c8e866da7c89e86ee629cfb

You did it! You deployed a smart contract 🎉 Be sure to save that Deployed to: address since we’ll need it later.

If we wanted to we can also verify the contract with an Etherscan API key.

forge v <CONTRACT_ADDRESS> \ 
src/MyNFT.sol:MyNFT \
-e <ETHERSCAN_API_KEY> \
--rpc-url https://sepolia.base.org

The last thing we need to do in this repo before moving on is getting the contract ABI. This is like a manual of instructions so other apps can interact with the contract on the blockchain. You should be able to find it under out/MyNFT.sol/MyNFT.json. In that file there is a whole bunch of stuff, but all you need is the abi array which looks something like this.

{
  "abi": [
    {
      "type": "constructor",
      "inputs": [],
      "stateMutability": "nonpayable"
    },
    {
      "type": "function",
      "name": "approve",
      "inputs": [
        {
          "name": "to",
          "type": "address",
          "internalType": "address"
        },
        {
          "name": "tokenId",
          "type": "uint256",
          "internalType": "uint256"
        }
      ],
      "outputs": [],
      "stateMutability": "nonpayable"
    },
    // rest of outputs
  ]
}

Copy that into a json file and we’ll get back to it!

Minting Client

The majority of minting use cases happen on a front end app, so in our tutorial we’ll be using Next.js. To get started run the following:

npx create-next-app@latest my_nft_client

It will ask a few questions where you can just choose all the defaults options. Once you’re done we need to install some dependencies.

After that we will need to setup some environment variables. You can do this by making a new file in the root of the project called .env.local with the following values put in.

NEXT_PUBLIC_CONTRACT_ADDRESS=
PINATA_JWT=

Naturally you will want to paste in the contract address from our previously deployed contract, and our Pinata JWT that we got when creating an API key.

Since we’ll be working with window.ethereum we’re going to make a few tweaks to the config files to make sure everything runs smoothly. First make a new file in the root of the project again called new-types.d.ts with the following contents.

interface Window {
  ethereum: any;
}

Then inside the existing tsconfig.json file, locate the "include" key and put our new file as a value in the array, like so.

"include": ["new-types.d.ts", "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],

Now we can start building some stuff. To begin with we’ll setup our Viem code. Remember that contract ABI we took a look at earlier? Now we’re ready for it! Make a folder called utils then put a file called contract.json and paste in the ABI file. Piece of cake.

With that setup let’s go into the main file under app/page.tsx and put in the following code.

"use client";

import { useState } from "react";
import { createPublicClient, createWalletClient, custom, http, type Address } from "viem";
import contractData from "@/utils/contract.json";
import { baseSepolia } from "viem/chains";

export default function Home() {
  const [account, setAccount] = useState<Address>();

  async function connect() {
    const walletClient = createWalletClient({
      chain: baseSepolia,
      transport: custom(window.ethereum),
    });

    const [address] = await walletClient.requestAddresses();
    setAccount(address);
  }

  async function mintNft() {
    if (!account) return;

    const walletClient = createWalletClient({
      chain: baseSepolia,
      transport: custom(window.ethereum),
    });
    const publicClient = createPublicClient({
      chain: baseSepolia,
      transport: http(),
    });

    const { request } = await publicClient.simulateContract({
      account,
      address: process.env.NEXT_PUBLIC_CONTRACT_ADDRESS as `0x`,
      abi: contractData.abi,
      functionName: "safeMint",
      args: [account, `ipfs://`],
    });

    const res = await walletClient.writeContract(request);
    alert(res);
  }

  return (
    <div className="flex flex-col justify-center items-center min-h-screen gap-4">
      {account ? (
        <>
          <div>Connected: {account}</div>
          <button
            className="border border-black rounded-md p-2"
            onClick={mintNft}
          >
            Mint NFT
          </button>
        </>
      ) : (
        <button
          className="border border-black rounded-md p-2"
          onClick={connect}
        >
          Connect Wallet
        </button>
      )}
    </div>
  );
}

This basically setups all the Web3 functionality we need. First we declare a state for an account, then make a function to set it by using the walletClient to request any browser wallets we might encounter in our app. Once its set we can display a button that will run mintNft(). In that function we first use our publicClient to simulate a transaction on the contract; this helps catch any errors before they even happen. In the request we’ll pass in the connected account, the address of our contract, the ABI, the name of the function, and the arguments. Then we can pass that request into the walletClient method writeContract.

Now this is all well and good, but if we minted this now the NFT would be empty since our argument for the token URI is just an empty ipfs://! We need a way to add data and content to the NFT, which is what we’ll do now using Pinata. There are several ways to handle uploads in Next.js, but one we’re fond of is setting up a secure API route in Next where we generate a temporary API key using our admin API key. This key will only have two uses, and even still we’ll revoke it after its used.

To do this make a new route and file under app/api/key/route.ts and put the following contents in.

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: 2,
    });
    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 });
  }
}

This declares a route where we can create an API key just for our client side upload. In those key permissions we’re able to scope them to just the endpoints we need and reduce the max uses just to two; very nice!

With keys taken care of, let’s make some upload functions that we can use with our NFT minting page. Back in the utils folder make a file called uploads.ts with the following contents.

export const generatePinataKey = async () => {
  try {
    const tempKey = await fetch("/api/key", {
      method: "GET",
      headers: {
        "Content-Type": "application/json",
      },
    });
    const keyData = await tempKey.json();
    return keyData;
  } catch (error) {
    console.log("error making API key:", error);
    throw error;
  }
};

export async function uploadFile(selectedFile: File | undefined, keyToUse: string) {
  if(!selectedFile){
    console.log('no file provided!')
    return
  }
  try {
    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 ${keyToUse}`,
        },
        body: formData,
      },
    );
    console.log({ uploadResStatus: uploadRes.status });
    if (uploadRes.status != 200) {
      throw Error;
    }
    const uploadResJson = await uploadRes.json();
    return uploadResJson.IpfsHash;
  } catch (error) {
    console.log("Error uploading file:", error);
  }
}

export async function uploadJson(content: any, keyToUse: string) {
  try {
    const data = JSON.stringify({
      pinataContent: {
        name: content.name,
        description: content.description,
        image: `ipfs://${content.image}`,
        external_url: content.external_url
      },
      pinataOptions: {
        cidVersion: 1,
      },
    });

    const uploadRes = await fetch(
      "https://api.pinata.cloud/pinning/pinJSONToIPFS",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${keyToUse}`,
        },
        body: data,
      },
    );
    const uploadResJson = await uploadRes.json();
    const cid = uploadResJson.IpfsHash;
    console.log(cid);
    return cid
  } catch (error) {
    console.log("Error uploading file:", error);
  }
}

It’s a far bit of code but we essentially have three primary functions:

  • generatePinataKey - Gets us a temporary API key for uploads

  • uploadFile - A function that lets us upload a file that we pass, along with a temporary API key.

  • uploadJson - A function we’ll use to create our NFT metadata that takes in an object with all the data we need.

When it comes to IPFS and NFTs, there’s a general flow that is pretty standard. First we need to upload our primary content. Since IPFS supports any kind of content, it could be anything, like an image, an audio file, or even 3D / HTML content. For this we’ll just stick with an image. Once we upload it we’ll get a CID for that file. The we need to use that image CID in metadata json file with all the information about our NFT, so in the end we have a nested data situation. To make this happen in our app let’s adjust our page.tsx code one more time.

"use client";

import { useState } from "react";
import { createPublicClient, createWalletClient, custom, http, type Address } from "viem";
import contractData from "@/utils/contract.json";
import {
  generatePinataKey,
  uploadFile,
  uploadJson,
} from "@/utils/uploads";
import { baseSepolia } from "viem/chains";

export default function Home() {
  const [account, setAccount] = useState<Address>();
  const [name, setName] = useState<string>();
  const [description, setDescription] = useState<string>();
  const [externalUrl, setExternalUrl] = useState<string>();
  const [file, setFile] = useState<File | undefined>();

  async function connect() {
    const walletClient = createWalletClient({
      chain: baseSepolia,
      transport: custom(window.ethereum),
    });

    const [address] = await walletClient.requestAddresses();
    setAccount(address);
  }

  async function mintNft() {
    if (!account) return;

    const walletClient = createWalletClient({
      chain: baseSepolia,
      transport: custom(window.ethereum),
    });
    const publicClient = createPublicClient({
      chain: baseSepolia,
      transport: http(),
    });

    const keyData = await generatePinataKey();

    const fileCID = await uploadFile(file, keyData.JWT);

    const metadata = {
      name: name,
      description: description,
      image: fileCID,
      external_url: externalUrl,
    };

    const uriCID = await uploadJson(metadata, keyData.JWT);

    const { request } = await publicClient.simulateContract({
      account,
      address: process.env.NEXT_PUBLIC_CONTRACT_ADDRESS as `0x`,
      abi: contractData.abi,
      functionName: "safeMint",
      args: [account, `ipfs://${uriCID}`],
    });

    const res = await walletClient.writeContract(request);
    alert(res);
  }

  return (
    <div className="flex flex-col justify-center items-center min-h-screen gap-4">
      {account ? (
        <>
          <div>Connected: {account}</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
            className="border border-black rounded-md p-2"
            onClick={mintNft}
          >
            Mint NFT
          </button>
        </>
      ) : (
        <button
          className="border border-black rounded-md p-2"
          onClick={connect}
        >
          Connect Wallet
        </button>
      )}
    </div>
  );
}

Let’s go over the changes we made here. First we added some React state to handle inputs in the UI like the name, description, externalUrl, and file. Then in our mintNft function we’ll handle the flow mentioned earlier:

  • Generate keyData, our API key, with generatePinataKey

  • Use the keyData to upload the file a user has selected and get the fileCID back

  • Declare a metadata object with all the details by the user, including the fileCID

  • Upload that metadata using uploadJson and get our uriCID back

  • Pass in the uriCID into the args of our contract request

That’s it! You now have an app that can mint NFTs! Of course this is just the beginning of what you can do with these tools. If you want a reference for each of these repos, you can find the smart contract here, and the client app here. Whether its a memecoin machine or a minting dApp, Pinata is here to help you scale with all your media needs on IPFS.

Happy Pinning!

Pinata logo
Subscribe to Pinata and never miss a post.
  • Loading comments...