The Real Work: Minting, Trading, and Verifying NFTs
Forget the hype articles. If you're building systems where AI agents need to interact with NFTs, you need code that actually works. Not tutorials that skip error handling. Not examples that assume perfect network conditions. Real, production-grade patterns.
I've been shipping NFT infrastructure for agents for the past year. Here's everything I wish someone had told me before I burned through $500 in failed transactions figuring it out myself.
This guide covers:
- Minting NFTs with proper metadata and gas estimation
- Trading via OpenSea's Seaport protocol and direct transfers
- Verifying ownership, metadata, and provenance
- Batch operations for efficiency
- Error handling and retry logic
- Security: approvals, reentrancy, front-running
All examples use ethers.js v6 and viem on Base (low gas, fast finality). Everything here runs on mainnet.
Minting NFTs: Getting It Right
Minting an NFT is conceptually simple: call a mint() function on an ERC-721 contract. In practice, you need to handle metadata upload, gas estimation, transaction retries, and a dozen edge cases.
Step 1: Upload Metadata to IPFS
NFT metadata lives off-chain (usually IPFS). The token URI points to a JSON file with name, description, image, and attributes.
import { create, IPFSHTTPClient } from 'ipfs-http-client';
import fs from 'fs';
interface NFTMetadata {
name: string;
description: string;
image: string;
attributes?: Array<{ trait_type: string; value: string | number }>;
}
async function uploadToIPFS(
ipfs: IPFSHTTPClient,
metadata: NFTMetadata
): Promise<string> {
const metadataJSON = JSON.stringify(metadata);
const result = await ipfs.add(metadataJSON);
return `ipfs://${result.path}`;
}
async function uploadImageAndMetadata(
imagePath: string,
metadata: Omit<NFTMetadata, 'image'>
): Promise<string> {
// Connect to IPFS (use Infura, Pinata, or your own node)
const ipfs = create({
url: 'https://ipfs.infura.io:5001',
headers: {
authorization: `Basic ${Buffer.from(
`${process.env.INFURA_PROJECT_ID}:${process.env.INFURA_SECRET}`
).toString('base64')}`
}
});
// 1. Upload image
const imageBuffer = fs.readFileSync(imagePath);
const imageResult = await ipfs.add(imageBuffer);
const imageURI = `ipfs://${imageResult.path}`;
// 2. Create and upload metadata
const fullMetadata: NFTMetadata = {
...metadata,
image: imageURI
};
const metadataURI = await uploadToIPFS(ipfs, fullMetadata);
return metadataURI;
}
Gotcha #1: IPFS uploads can fail silently. Always verify the CID is reachable before minting. I learned this the hard way when we minted 50 NFTs pointing to metadata that never propagated across the network.
async function verifyIPFSContent(cid: string, timeoutMs = 30000): Promise<boolean> {
const gateways = [
`https://ipfs.io/ipfs/${cid}`,
`https://cloudflare-ipfs.com/ipfs/${cid}`,
`https://gateway.pinata.cloud/ipfs/${cid}`
];
for (const gateway of gateways) {
try {
const response = await fetch(gateway, {
signal: AbortSignal.timeout(timeoutMs)
});
if (response.ok) return true;
} catch {
continue;
}
}
return false;
}
Step 2: Mint the NFT with Proper Gas Estimation
Here's a production-ready mint function using ethers.js v6:
import { ethers } from 'ethers';
interface MintParams {
contractAddress: string;
recipientAddress: string;
tokenURI: string;
rpcUrl: string;
privateKey: string;
}
async function mintNFT(params: MintParams): Promise<string> {
const provider = new ethers.JsonRpcProvider(params.rpcUrl);
const wallet = new ethers.Wallet(params.privateKey, provider);
// Standard ERC-721 mint function ABI
const abi = [
"function mint(address to, string uri) returns (uint256)",
"event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
];
const contract = new ethers.Contract(params.contractAddress, abi, wallet);
// Estimate gas with 20% buffer
let gasLimit: bigint;
try {
const estimated = await contract.mint.estimateGas(
params.recipientAddress,
params.tokenURI
);
gasLimit = (estimated * 120n) / 100n; // 20% buffer
} catch (error) {
console.error('Gas estimation failed:', error);
gasLimit = 200000n; // Fallback
}
// Get current gas price with priority fee
const feeData = await provider.getFeeData();
const maxFeePerGas = feeData.maxFeePerGas || ethers.parseUnits('0.1', 'gwei');
const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas || ethers.parseUnits('0.05', 'gwei');
// Send transaction
const tx = await contract.mint(params.recipientAddress, params.tokenURI, {
gasLimit,
maxFeePerGas,
maxPriorityFeePerGas
});
console.log(`Transaction sent: ${tx.hash}`);
// Wait for confirmation
const receipt = await tx.wait(1); // 1 confirmation
if (!receipt) {
throw new Error('Transaction failed');
}
// Extract token ID from Transfer event
const transferEvent = receipt.logs.find((log: any) => {
try {
const parsed = contract.interface.parseLog(log);
return parsed?.name === 'Transfer';
} catch {
return false;
}
});
if (transferEvent) {
const parsed = contract.interface.parseLog(transferEvent);
const tokenId = parsed?.args.tokenId.toString();
console.log(`Minted token ID: ${tokenId}`);
return tokenId;
}
throw new Error('Transfer event not found');
}
Gotcha #2: Gas estimation can fail if your wallet doesn't have permission to mint, or if the contract has state that would cause a revert. Always catch estimation errors and have a fallback gas limit.
Gotcha #3: On Base, gas prices are usually 0.01-0.1 gwei. If you hardcode 50+ gwei (Ethereum mainnet levels), you're burning money for no reason.
Step 3: Retry Logic for Failed Transactions
Networks are unreliable. Transactions get dropped, RPCs timeout, and nonces get out of sync.
async function mintWithRetry(
params: MintParams,
maxRetries = 3
): Promise<string> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`Mint attempt ${attempt}/${maxRetries}`);
return await mintNFT(params);
} catch (error: any) {
lastError = error;
console.error(`Attempt ${attempt} failed:`, error.message);
// Check if error is retryable
if (
error.message.includes('nonce') ||
error.message.includes('timeout') ||
error.message.includes('network')
) {
// Wait before retry with exponential backoff
const waitTime = 2000 * Math.pow(2, attempt - 1);
console.log(`Retrying in ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
// Non-retryable error (e.g., insufficient balance, contract revert)
throw error;
}
}
throw new Error(`Mint failed after ${maxRetries} attempts: ${lastError?.message}`);
}
Using Viem (Alternative to ethers.js)
Viem is faster and more type-safe than ethers. Here's the same mint operation:
import { createPublicClient, createWalletClient, http } from 'viem';
import { base } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';
async function mintNFTViem(params: MintParams): Promise<string> {
const account = privateKeyToAccount(params.privateKey as `0x${string}`);
const publicClient = createPublicClient({
chain: base,
transport: http(params.rpcUrl)
});
const walletClient = createWalletClient({
account,
chain: base,
transport: http(params.rpcUrl)
});
// Estimate gas
const gasEstimate = await publicClient.estimateContractGas({
address: params.contractAddress as `0x${string}`,
abi: [{
name: 'mint',
type: 'function',
stateMutability: 'nonpayable',
inputs: [
{ name: 'to', type: 'address' },
{ name: 'uri', type: 'string' }
],
outputs: [{ name: 'tokenId', type: 'uint256' }]
}],
functionName: 'mint',
args: [params.recipientAddress as `0x${string}`, params.tokenURI],
account
});
// Send transaction
const hash = await walletClient.writeContract({
address: params.contractAddress as `0x${string}`,
abi: [{
name: 'mint',
type: 'function',
stateMutability: 'nonpayable',
inputs: [
{ name: 'to', type: 'address' },
{ name: 'uri', type: 'string' }
],
outputs: [{ name: 'tokenId', type: 'uint256' }]
}],
functionName: 'mint',
args: [params.recipientAddress as `0x${string}`, params.tokenURI],
gas: (gasEstimate * 120n) / 100n
});
console.log(`Transaction hash: ${hash}`);
// Wait for receipt
const receipt = await publicClient.waitForTransactionReceipt({ hash });
return receipt.transactionHash;
}
Viem is my preference for new projects. Better TypeScript support, faster, and less dependency bloat.
Trading NFTs: Seaport and Direct Transfers
Once minted, agents need to trade. Two main paths: direct transfers (simple, low-level) and marketplace protocols (complex, but standard).
Direct Transfers: The Simple Path
async function transferNFT(
contractAddress: string,
fromAddress: string,
toAddress: string,
tokenId: string,
privateKey: string,
rpcUrl: string
): Promise<string> {
const provider = new ethers.JsonRpcProvider(rpcUrl);
const wallet = new ethers.Wallet(privateKey, provider);
const abi = [
"function safeTransferFrom(address from, address to, uint256 tokenId)"
];
const contract = new ethers.Contract(contractAddress, abi, wallet);
const tx = await contract.safeTransferFrom(fromAddress, toAddress, tokenId);
const receipt = await tx.wait();
console.log(`Transfer complete: ${receipt.hash}`);
return receipt.hash;
}
Gotcha #4: Use safeTransferFrom, not transferFrom. The safe version checks if the recipient can receive ERC-721 tokens (implements onERC721Received). If you transfer to a contract that can't handle NFTs, they're locked forever.
OpenSea Seaport: The Standard for Marketplaces
Seaport is OpenSea's protocol for NFT trades. It's complex, but it's the standard. If you want marketplace integration, you need Seaport.
Here's a simplified example of creating a listing:
import { Seaport } from '@opensea/seaport-js';
import { ethers } from 'ethers';
async function createSeaportListing(
nftContractAddress: string,
tokenId: string,
priceInEth: string,
privateKey: string,
rpcUrl: string
) {
const provider = new ethers.JsonRpcProvider(rpcUrl);
const signer = new ethers.Wallet(privateKey, provider);
const seaport = new Seaport(signer as any);
// Create listing
const { executeAllActions } = await seaport.createOrder({
offer: [
{
itemType: 2, // ERC-721
token: nftContractAddress,
identifier: tokenId
}
],
consideration: [
{
amount: ethers.parseEther(priceInEth).toString(),
recipient: await signer.getAddress()
}
]
});
const order = await executeAllActions();
console.log('Listing created:', order);
return order;
}
Gotcha #5: Seaport requires the NFT owner to approve the Seaport contract before listing. This is a separate transaction:
async function approveSeaport(
nftContractAddress: string,
seaportAddress: string,
privateKey: string,
rpcUrl: string
) {
const provider = new ethers.JsonRpcProvider(rpcUrl);
const signer = new ethers.Wallet(privateKey, provider);
const abi = ["function setApprovalForAll(address operator, bool approved)"];
const contract = new ethers.Contract(nftContractAddress, abi, signer);
const tx = await contract.setApprovalForAll(seaportAddress, true);
await tx.wait();
console.log('Seaport approved');
}
Gotcha #6: Seaport on Base uses a different deployment address than Ethereum mainnet. Always check the docs for the correct address.
Base Seaport contract: 0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC
Fulfilling a Seaport Order
async function fulfillSeaportOrder(
order: any, // Order object from createOrder
privateKey: string,
rpcUrl: string
) {
const provider = new ethers.JsonRpcProvider(rpcUrl);
const signer = new ethers.Wallet(privateKey, provider);
const seaport = new Seaport(signer as any);
const { executeAllActions } = await seaport.fulfillOrder({
order,
accountAddress: await signer.getAddress()
});
const result = await executeAllActions();
console.log('Order fulfilled:', result);
return result;
}
Verification: Ownership, Metadata, and Provenance
Verification is critical. Before accepting an NFT as proof of anything, you need to verify:
Verify Ownership
async function verifyOwnership(
contractAddress: string,
tokenId: string,
expectedOwner: string,
rpcUrl: string
): Promise<boolean> {
const provider = new ethers.JsonRpcProvider(rpcUrl);
const abi = ["function ownerOf(uint256 tokenId) view returns (address)"];
const contract = new ethers.Contract(contractAddress, abi, provider);
try {
const owner = await contract.ownerOf(tokenId);
return owner.toLowerCase() === expectedOwner.toLowerCase();
} catch (error) {
console.error('Ownership check failed:', error);
return false;
}
}
Verify and Parse Metadata
import fetch from 'node-fetch';
interface ParsedMetadata {
name: string;
description: string;
image: string;
attributes: Array<{ trait_type: string; value: any }>;
}
async function fetchAndValidateMetadata(
contractAddress: string,
tokenId: string,
rpcUrl: string
): Promise<ParsedMetadata | null> {
const provider = new ethers.JsonRpcProvider(rpcUrl);
const abi = ["function tokenURI(uint256 tokenId) view returns (string)"];
const contract = new ethers.Contract(contractAddress, abi, provider);
// Get token URI
const tokenURI = await contract.tokenURI(tokenId);
// Handle IPFS URIs
let metadataUrl = tokenURI;
if (tokenURI.startsWith('ipfs://')) {
const cid = tokenURI.replace('ipfs://', '');
metadataUrl = `https://ipfs.io/ipfs/${cid}`;
}
// Fetch metadata
const response = await fetch(metadataUrl);
if (!response.ok) {
console.error(`Failed to fetch metadata: ${response.statusText}`);
return null;
}
const metadata = await response.json();
// Validate required fields
if (!metadata.name || !metadata.image) {
console.error('Invalid metadata: missing required fields');
return null;
}
return metadata as ParsedMetadata;
}
Gotcha #7: IPFS URIs can resolve differently depending on the gateway. Always try multiple gateways if one fails.
Check Provenance (Verify Minter)
async function verifyMinter(
contractAddress: string,
tokenId: string,
expectedMinter: string,
rpcUrl: string
): Promise<boolean> {
const provider = new ethers.JsonRpcProvider(rpcUrl);
// Query Transfer event where from = 0x0 (mint event)
const abi = [
"event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
];
const contract = new ethers.Contract(contractAddress, abi, provider);
const filter = contract.filters.Transfer(
ethers.ZeroAddress, // from (0x0 for mints)
null, // to (any)
tokenId // specific token ID
);
const events = await contract.queryFilter(filter);
if (events.length === 0) {
console.error('No mint event found');
return false;
}
const mintEvent = events[0];
const transaction = await provider.getTransaction(mintEvent.transactionHash);
if (!transaction) {
console.error('Transaction not found');
return false;
}
return transaction.from.toLowerCase() === expectedMinter.toLowerCase();
}
Batch Operations: Efficiency Matters
If you're minting hundreds of NFTs, individual transactions will kill you on time and gas. Use batch minting.
Batch Mint Contract Pattern
// ERC-721 with batch minting
contract BatchMintNFT is ERC721URIStorage {
uint256 private _tokenIdCounter;
function batchMint(
address[] calldata recipients,
string[] calldata tokenURIs
) external onlyOwner {
require(recipients.length == tokenURIs.length, "Length mismatch");
for (uint256 i = 0; i < recipients.length; i++) {
uint256 tokenId = _tokenIdCounter++;
_safeMint(recipients[i], tokenId);
_setTokenURI(tokenId, tokenURIs[i]);
}
}
}
Batch Mint Call
async function batchMintNFTs(
contractAddress: string,
recipients: string[],
tokenURIs: string[],
privateKey: string,
rpcUrl: string
): Promise<string> {
const provider = new ethers.JsonRpcProvider(rpcUrl);
const wallet = new ethers.Wallet(privateKey, provider);
const abi = [
"function batchMint(address[] recipients, string[] tokenURIs)"
];
const contract = new ethers.Contract(contractAddress, abi, wallet);
// Estimate gas (batch operations can be expensive)
const gasEstimate = await contract.batchMint.estimateGas(recipients, tokenURIs);
const gasLimit = (gasEstimate * 120n) / 100n;
const tx = await contract.batchMint(recipients, tokenURIs, { gasLimit });
const receipt = await tx.wait();
console.log(`Batch minted ${recipients.length} NFTs: ${receipt.hash}`);
return receipt.hash;
}
Gotcha #8: Batch operations can exceed block gas limits. On Base, the block gas limit is ~30M. A complex mint might use 100-200k gas per token. Do the math before trying to mint 500 tokens in one transaction.
Security: Approval Patterns and Protections
Approval Safety
Never give unlimited approvals. Limit approvals to specific tokens or amounts:
async function safeApproval(
nftContractAddress: string,
operatorAddress: string,
tokenId: string, // Approve specific token, not all
privateKey: string,
rpcUrl: string
) {
const provider = new ethers.JsonRpcProvider(rpcUrl);
const signer = new ethers.Wallet(privateKey, provider);
const abi = ["function approve(address to, uint256 tokenId)"];
const contract = new ethers.Contract(nftContractAddress, abi, signer);
const tx = await contract.approve(operatorAddress, tokenId);
await tx.wait();
console.log(`Approved token ${tokenId} for ${operatorAddress}`);
}
Gotcha #9: setApprovalForAll gives an operator permission to transfer ALL your NFTs in that collection. Only use it with trusted contracts (like Seaport). For one-off transfers, use approve(address, tokenId).
Reentrancy Protection
If you're building a contract that accepts NFTs, protect against reentrancy:
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureNFTReceiver is ReentrancyGuard {
function acceptNFT(
address nftContract,
uint256 tokenId
) external nonReentrant {
// Safe to call external contracts here
IERC721(nftContract).safeTransferFrom(msg.sender, address(this), tokenId);
// Process NFT
processNFT(tokenId);
}
}
Front-Running Protection
On public chains, your transactions are visible in the mempool before they're mined. Bots can front-run your trades.
Mitigation strategies:
// Example: Commit-reveal for NFT purchase
async function commitPurchase(
orderHash: string,
privateKey: string,
rpcUrl: string
): Promise<string> {
const provider = new ethers.JsonRpcProvider(rpcUrl);
const wallet = new ethers.Wallet(privateKey, provider);
// Commit phase: submit hash of intent
const commitHash = ethers.keccak256(
ethers.solidityPacked(['bytes32', 'address'], [orderHash, wallet.address])
);
// Submit commit (doesn't reveal order details)
// ... contract call ...
// Wait for blocks to pass (prevent front-running)
// ... wait ...
// Reveal phase: submit actual order
// ... fulfill order ...
return commitHash;
}
Key Takeaways
- Minting: Upload metadata to IPFS, verify CID propagation, estimate gas with buffers, handle retries
- Gas optimization: Use Base for low fees (~$0.01-0.05), batch operations when possible, avoid overpaying on maxFeePerGas
- Trading: Use
safeTransferFromfor direct transfers, Seaport for marketplace integration, approve carefully - Verification: Always check ownership on-chain, validate metadata, verify mint provenance via Transfer events
- Batch operations: Essential for scale, but watch block gas limits (Base: ~30M gas/block)
- Security: Limit approvals to specific tokens, use ReentrancyGuard, protect against front-running with private mempools or commit-reveal
FAQ
Q: Should I use ethers.js or viem?
If you're starting fresh, use viem. It's faster (no promise overhead), more type-safe (better TypeScript inference), and smaller (less dependencies). Ethers is fine if you're already invested in it, but viem is the future. The learning curve is steeper, but it's worth it.
Q: How do I estimate gas accurately for complex contract calls?
Use estimateGas() from your provider, then add a 10-20% buffer. Never trust the estimate completely — networks are unpredictable. For safety-critical operations, use a fallback gas limit if estimation fails. On Base, 200k gas covers most ERC-721 mints. Check recent transactions on BaseScan to see actual gas usage for similar operations.
Q: What's the best way to handle IPFS reliability?
Use a pinning service (Pinata, Infura, NFT.Storage) to ensure your content stays available. Always verify CIDs are reachable via multiple gateways before minting. For critical data, consider redundancy: pin to multiple services or use Arweave (permanent storage). IPFS is great for decentralization, but it's not "set and forget" — you need active pinning.
Q: How do I prevent agents from accidentally approving malicious contracts?
Implement an allowlist of trusted contract addresses. Before any approve or setApprovalForAll call, check if the operator is in the allowlist. For agents operating autonomously, never give blanket approval permissions. Require human approval for new contract interactions. Store the allowlist in a config file that's version controlled and reviewed.
Q: What's the difference between ERC-721 and ERC-1155?
ERC-721 is one NFT per token ID (non-fungible only). ERC-1155 supports both fungible and non-fungible tokens in one contract (useful for game items, where you might have 100 copies of "Health Potion" but unique "Legendary Sword"). For agent credentials and identity, ERC-721 is standard. For gaming or tokenized resources, ERC-1155 gives more flexibility.