Реалізація мінтингу NFT через веб-інтерфейс
Мінтинг NFT — це транзакція в блокчейні, яка створює новий токен у контракті. Веб-інтерфейс для мінтингу — це UI поверх смарт-контракту: підключення гаманця, перевірка умов (whitelist, публічна продажа, ліміти), відправка транзакції, відстеження статусу, фінальний екран з токеном.
Складність реалізації залежить від механіки контракту: вільний мінт, whitelist через merkle tree, ERC-2981 royalties, крива ціни, максимум на гаманець.
Аналіз контракту перед розробкою
Перший крок — вивчити ABI контракту. Типовий мінт-контракт (ERC-721A — більш популярний за чистий ERC-721 для дешевого пакетного мінтингу):
// Типові функції мінт-контракту
function mint(uint256 quantity) external payable;
function whitelistMint(uint256 quantity, bytes32[] calldata proof) external payable;
function totalSupply() external view returns (uint256);
function maxSupply() external view returns (uint256);
function mintPrice() external view returns (uint256);
function maxPerWallet() external view returns (uint256);
function saleState() external view returns (uint8); // 0=paused, 1=whitelist, 2=public
function numberMinted(address owner) external view returns (uint256);
Читання стану контракту
// lib/mintContract.ts
import { createPublicClient, http, parseAbi } from 'viem';
import { mainnet } from 'viem/chains';
const MINT_ABI = parseAbi([
'function totalSupply() view returns (uint256)',
'function maxSupply() view returns (uint256)',
'function mintPrice() view returns (uint256)',
'function maxPerWallet() view returns (uint256)',
'function saleState() view returns (uint8)',
'function numberMinted(address) view returns (uint256)',
'function mint(uint256 quantity) payable',
'function whitelistMint(uint256 quantity, bytes32[] proof) payable',
]);
export async function getMintState(
contractAddress: `0x${string}`,
walletAddress: `0x${string}` | null,
) {
const client = createPublicClient({ chain: mainnet, transport: http() });
const calls = [
{ address: contractAddress, abi: MINT_ABI, functionName: 'totalSupply' },
{ address: contractAddress, abi: MINT_ABI, functionName: 'maxSupply' },
{ address: contractAddress, abi: MINT_ABI, functionName: 'mintPrice' },
{ address: contractAddress, abi: MINT_ABI, functionName: 'maxPerWallet' },
{ address: contractAddress, abi: MINT_ABI, functionName: 'saleState' },
...(walletAddress ? [{
address: contractAddress,
abi: MINT_ABI,
functionName: 'numberMinted',
args: [walletAddress],
}] : []),
] as const;
const results = await client.multicall({ contracts: calls });
return {
totalSupply: results[0].result as bigint,
maxSupply: results[1].result as bigint,
mintPrice: results[2].result as bigint,
maxPerWallet: results[3].result as bigint,
saleState: results[4].result as number,
numberMinted: walletAddress ? (results[5].result as bigint) : 0n,
};
}
Whitelist через Merkle Tree
Більшість сучасних мінтів використовує merkle proof замість зберігання всіх whitelist-адрес у контракті. Proof генерується на фронтенді за адресою користувача:
// lib/merkle.ts
import { MerkleTree } from 'merkletreejs';
import { keccak256, encodePacked } from 'viem';
// allowlist.json — масив адрес з CMS або API
import allowlist from '@/data/allowlist.json';
function hashLeaf(address: string): `0x${string}` {
return keccak256(encodePacked(['address'], [address as `0x${string}`]));
}
const leaves = allowlist.map(hashLeaf);
const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });
export function getMerkleProof(address: string): `0x${string}`[] {
const leaf = hashLeaf(address);
return tree.getHexProof(leaf) as `0x${string}`[];
}
export function isWhitelisted(address: string): boolean {
const leaf = hashLeaf(address);
return tree.verify(tree.getHexProof(leaf), leaf, tree.getRoot());
}
Транзакція мінтингу
// hooks/useMint.ts
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther } from 'viem';
export function useMint(contractAddress: `0x${string}`) {
const { writeContract, data: txHash, isPending, error } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
const mint = async (quantity: number, price: bigint) => {
writeContract({
address: contractAddress,
abi: MINT_ABI,
functionName: 'mint',
args: [BigInt(quantity)],
value: price * BigInt(quantity),
});
};
const whitelistMint = async (
quantity: number,
price: bigint,
proof: `0x${string}`[],
) => {
writeContract({
address: contractAddress,
abi: MINT_ABI,
functionName: 'whitelistMint',
args: [BigInt(quantity), proof],
value: price * BigInt(quantity),
});
};
return { mint, whitelistMint, txHash, isPending, isConfirming, isSuccess, error };
}
UI компонент мінтингу
// components/MintWidget.tsx
import { useState } from 'react';
import { formatEther } from 'viem';
import { useAccount } from 'wagmi';
import { useMint } from '@/hooks/useMint';
import { getMintState } from '@/lib/mintContract';
import { getMerkleProof, isWhitelisted } from '@/lib/merkle';
import { useQuery } from '@tanstack/react-query';
const CONTRACT = process.env.NEXT_PUBLIC_CONTRACT_ADDRESS as `0x${string}`;
export function MintWidget() {
const { address, isConnected } = useAccount();
const [quantity, setQuantity] = useState(1);
const { mint, whitelistMint, isPending, isConfirming, isSuccess, txHash } = useMint(CONTRACT);
const { data: state } = useQuery({
queryKey: ['mintState', address],
queryFn: () => getMintState(CONTRACT, address ?? null),
refetchInterval: 10_000,
});
if (!state) return <MintSkeleton />;
const sold = Number(state.totalSupply);
const total = Number(state.maxSupply);
const priceEth = formatEther(state.mintPrice);
const remaining = total - sold;
const canMint = Number(state.maxPerWallet) - Number(state.numberMinted);
const handleMint = async () => {
if (!address) return;
if (state.saleState === 1) {
// Whitelist sale
if (!isWhitelisted(address)) return;
const proof = getMerkleProof(address);
await whitelistMint(quantity, state.mintPrice, proof);
} else {
await mint(quantity, state.mintPrice);
}
};
return (
<div className="space-y-6 rounded-2xl border border-white/10 bg-neutral-900 p-6">
{/* Прогрес */}
<div>
<div className="mb-2 flex justify-between text-sm">
<span>{sold} / {total} заминтено</span>
<span>{remaining} залишилось</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-neutral-800">
<div
className="h-full rounded-full bg-blue-500 transition-all"
style={{ width: `${(sold / total) * 100}%` }}
/>
</div>
</div>
{/* Кількість */}
<QuantitySelector
value={quantity}
onChange={setQuantity}
max={Math.min(canMint, remaining, 10)}
/>
{/* Загальна вартість */}
<div className="flex justify-between text-sm">
<span className="text-neutral-400">Вартість</span>
<span>{(parseFloat(priceEth) * quantity).toFixed(4)} ETH</span>
</div>
{/* Кнопка */}
<MintButton
state={state.saleState}
address={address}
isConnected={isConnected}
isPending={isPending || isConfirming}
isSuccess={isSuccess}
canMint={canMint > 0}
onClick={handleMint}
/>
{/* Статус транзакції */}
{txHash && (
<a
href={`https://etherscan.io/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
className="block text-center text-xs text-blue-400 hover:underline"
>
Переглянути транзакцію →
</a>
)}
</div>
);
}
Обробка помилок
Мінтинг не вдається з багатьох причин: недостатньо ETH, перевищено ліміт гаманця, продажа не активна, неверний proof. Помилки з viem містять ABI-декодовано повідомлення контракту:
import { ContractFunctionRevertedError, UserRejectedRequestError } from 'viem';
function parseMintError(error: Error): string {
if (error instanceof UserRejectedRequestError) {
return 'Транзакція відхилена в гаманці';
}
if (error instanceof ContractFunctionRevertedError) {
const reason = error.data?.errorName ?? error.message;
const messages: Record<string, string> = {
'ExceedsMaxPerWallet': 'Перевищено ліміт токенів на гаманець',
'SaleNotActive': 'Продажа ще не розпочалась',
'InvalidMerkleProof': 'Ваша адреса не у вайтлисті',
'InsufficientFunds': 'Недостатньо ETH',
'MaxSupplyReached': 'Усі токени заминтені',
};
return messages[reason] ?? `Помилка контракту: ${reason}`;
}
return 'Невідома помилка';
}
Часова шкала: інтерфейс мінтингу з публічним мінтом, прогрес-баром та базовою обробкою помилок — 2–3 дні. Повна реалізація з whitelist через merkle tree, багатофазною продажею та фінальним екраном з токеном — 4–6 днів.







