Open-source NFT image minter for Ethereum — CLI + web UI. Deploy an ERC-721A contract, upload images/metadata to IPFS (Pinata), and mint single or batch NFTs.
| Layer | Tool |
|---|---|
| Contracts | Foundry, Solidity 0.8.30, ERC-721A (Chiru Labs) |
| CLI | Node 20, TypeScript, commander, viem v2 |
| Web | Next.js 14 (App Router), wagmi v2, RainbowKit v2 |
| Storage | IPFS via Pinata |
| Monorepo | pnpm workspaces + Turborepo |
# 1. Install deps
pnpm install
# 2. Install Foundry deps
cd contracts && forge install && cd ..
# 3. Copy env + fill in keys
cp .env.example .env
# 4. Run contract tests
pnpm contracts:test
# 5. Build the CLI
pnpm --filter @nft-minter/cli build
# 6. Start the web app
pnpm --filter @nft-minter/web devThe fastest path to shipping a drop: hand this repo to a coding agent and say:
"Deploy my NFT collection to Sepolia"
A project-level skill at .claude/skills/nft-drop/SKILL.md
walks the agent through preflight checks (env vars, Foundry toolchain, build),
scaffolding, image placement, and the full drop command — including the
real-world gotchas that usually eat an afternoon: Pinata free-tier quotas,
missing Sepolia ETH, and OpenSea's metadata-indexing lag.
Works with any AI coding agent, not just Claude Code — the skill file is plain markdown, not a Claude-only format:
- Claude Code autoloads it from
.claude/skills/nft-drop/when your prompt matches the skill's triggers. Open the repo and ask. - Cursor, Cline, Windsurf, Continue, Aider, Codex CLI, etc. — point the
agent at
.claude/skills/nft-drop/SKILL.md(via@file, a project rules entry, or your system prompt) and ask it to do the drop. The runbook reads the same to any agent that can execute shell commands.
# 1. Scaffold a fresh collection folder with manifest + images/ dir
nft-mint init my-drop --name "My Drop" --count 10
# 2. Drop your 10 images into my-drop/images/ (named 1.png … 10.png)
# 3. One command: pin to IPFS, deploy, and batch-mint to the deployer
nft-mint drop \
--manifest my-drop/manifest.json \
--network sepolia \
--mintnft-mint drop prints the contract address, Etherscan link, and OpenSea URL when
it's done. That's it — no Pinata dashboard, no copying CIDs by hand.
# Pin images + metadata as two IPFS folders (baseURI is saved next to the manifest)
nft-mint upload --manifest ./my-drop/manifest.json
# Deploy — auto-reads name + baseURI + maxSupply from the manifest + saved upload
nft-mint deploy --manifest ./my-drop/manifest.json --price 0.01 --network sepolia
# Batch-mint the entire manifest in one tx (ERC-721A: cheap regardless of qty)
nft-mint batch-mint --contract 0x… --manifest ./my-drop/manifest.json --network sepolia --owner
# Mint a single token
nft-mint mint --contract 0x… --to 0xRecipient --quantity 1 --network sepolia
# Verify on Etherscan (deploy --verify does this automatically)
nft-mint verify --address 0x… --network sepolia --name "My Drop" --symbol MD \
--base-uri "ipfs://<cid>/" --max-supply 10 --price 0 --owner 0x…{
"name": "My Drop",
"tokens": [
{
"file": "images/1.png",
"name": "Token #1",
"description": "First token",
"attributes": [{ "trait_type": "background", "value": "blue" }]
}
]
}After upload / deploy, a .nft-mint.json file is written next to the manifest.
It caches imagesFolderCid, metadataFolderCid, baseUri, and the deployed
contract address so later commands (deploy, drop, batch-mint) can pick them
up automatically. Safe to commit or delete.
mint and batch-mint support --json, which silences human-friendly output
and emits a single JSON line to stdout containing txHash, blockNumber,
tokenIds, contract, recipient, network. Useful when driving the CLI
from a shell script:
RESULT=$(nft-mint batch-mint --contract 0x… --manifest ./m.json --network sepolia --owner --json)
echo "$RESULT" | jq '.tokenIds[]'The same logic is importable as an ES module. Use this when you want to drive mints and transfers from a backend (pre-minting into a holding wallet, user-claim flows, airdrop jobs, etc.) instead of running the CLI.
import {
runBatchMint,
runTransferNft,
createClients,
type MintResult,
type TransferResult,
} from "@nft-minter/cli";
import { createWalletClient, createPublicClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia } from "viem/chains";
// The caller builds its own viem clients — the SDK never touches process.env,
// so the signing key can come from Secrets Manager, a KMS wrapper, or any other
// signer you want to plug in.
const signer = privateKeyToAccount(process.env.SIGNER_KEY as `0x${string}`);
const transport = http(process.env.RPC_URL_SEPOLIA);
const publicClient = createPublicClient({ chain: sepolia, transport });
const walletClient = createWalletClient({ account: signer, chain: sepolia, transport });
const clients = { publicClient, walletClient, address: signer.address, chainName: "sepolia" as const };
// Pre-mint a batch to the signer.
const mint: MintResult = await runBatchMint({
contract: "0x2Fcde6D4cc5711326dC1b693E93C4e9fFF9d5206",
quantity: 10n,
ownerMint: true,
clients,
});
// mint.tokenIds → [1n, 2n, …, 10n] ← parsed from Transfer logs
// mint.txHash, mint.blockNumber, mint.recipient
// Later, transfer an already-minted token to a claimer.
const xfer: TransferResult = await runTransferNft({
contract: "0x2Fcde…",
from: signer.address,
to: userAddress,
tokenId: 3n,
clients,
});Local install (the package is not published to npm yet):
// your-app/package.json
{
"dependencies": {
"@nft-minter/cli": "file:../nft-minter-cli/packages/cli"
}
}Then in your app: pnpm install && pnpm build. The package's main points at
dist/sdk.js — CLI binary at dist/index.js is unaffected by imports.
The SDK does not enforce idempotency — that's the caller's job, and it matters whenever the mint/transfer request comes from an automated source (e.g., a winner list, a claim queue) that could fire twice. Recommended pattern:
Before sdk.runBatchMint:
1. INSERT INTO mint_jobs (batch_id, status='minting', requested_quantity, requested_at)
ON CONFLICT (batch_id) DO NOTHING RETURNING id;
2. If no row returned → another job is already running, abort.
After sdk.runBatchMint succeeds:
3. UPDATE mint_jobs SET status='minted', tx_hash, token_ids WHERE batch_id = …;
4. INSERT INTO nft_inventory (batch_id, token_id, token_uri, status='available') …;
If the caller crashes between steps 2 and 3:
5. Reconciliation job polls `status='minting'` rows older than N minutes.
6. For each, re-fetch receipt using the broadcasted tx hash (capture it inside
sdk.runBatchMint before awaiting the receipt by wrapping the call — or
instrument a DB write inside a viem walletClient middleware).
7. Alternatively, compare `contract.totalSupply()` before/after the window
to detect the mint happened without a known tx hash.
Same pattern for runTransferNft on claim flow — persist status='claiming'
before the SDK call, write claim_tx_hash on success. A claim attempt must
never re-execute if a prior attempt hasn't been reconciled.
The SDK returns typed MintResult / TransferResult objects (including
tokenIds parsed from Transfer logs) so persistence is a straightforward
database write — no log scraping on your side.
nft-minter-cli/
├── contracts/ # Foundry — EternaImageNFT (ERC-721A)
└── packages/
├── shared/ # ABI, chain config, metadata schema (zod)
├── cli/ # `nft-mint` binary
└── web/ # Next.js app
pnpm lint # lint JS/TS packages (eslint)
pnpm test # test JS/TS packages
pnpm build # build JS/TS packages
pnpm format # format with prettier
# Contracts live in their own Foundry toolchain:
pnpm contracts:lint # forge fmt --check
pnpm contracts:build # forge build
pnpm contracts:test # forge test -vvvUse a fresh hardware-wallet-backed key, not your dev wallet. The CLI's deploy command reads PRIVATE_KEY from .env; for mainnet, prefer using cast wallet import + forge script --account instead of a raw private key. See Foundry keystore docs.
MIT — see LICENSE.