Аудит безпеки NFT-коллекції
NFT-контракти виглядають простіше, ніж DeFi протоколи — немає складної математики, немає liquidity pools. На практиці це хибне відчуття безпеки: NFT колекції регулярно втрачають кошти через вразливості в mint логіці, reentrancy у ERC-721 transfer callback, некоректний randomness та проблеми з royalty. Аудит NFT-контракту — це не формальність, це вимога ринку: більшість маркетплейсів та інвесторів розглядають аудит як baseline.
Що перевіряється в NFT-аудиті
ERC-721 стандарт та реалізація
Перший блок: відповідність ERC-721 стандарту та коректність базової реалізації. OpenZeppelin або власний контракт?
Власна реалізація ERC-721 — невідкладна red flag. Якщо команда не використовує OZ ERC721, потрібно тщательно перевірити:
- Коректність
safeTransferFrom— викликonERC721Receivedу контракті-одержувачі - Правильність
approveтаsetApprovalForAllлогіки - Захист від передачі до
address(0)без намірення на burn - Коректний підрахунок
balanceOfтаownerOf
Reentrancy через ERC-721 callback
Це найбільш поширена критична вразливість у NFT. safeTransferFrom викликає onERC721Received на контракті-одержувачі — це зовнішній виклик посередині транзакції.
// ВРАЗЛИВИЙ контракт
contract VulnerableNFT is ERC721 {
uint256 public mintPrice = 0.1 ether;
mapping(address => uint256) public minted;
function mint(uint256 quantity) external payable {
require(msg.value >= mintPrice * quantity, "Insufficient payment");
for (uint256 i = 0; i < quantity; i++) {
uint256 tokenId = ++_tokenCounter;
// safeTransferFrom → викликає onERC721Received → reentrancy!
_safeMint(msg.sender, tokenId);
// State оновлюється ПІСЛЯ _safeMint — вразливо
minted[msg.sender]++;
}
}
}
Атака: зловмисник створює контракт з onERC721Received, який при виклику знову викликає mint. Якщо minted лічильник оновлюється після _safeMint — можна перевищити ліміт mint per address.
// ВИПРАВЛЕНА версія: Checks-Effects-Interactions
function mint(uint256 quantity) external payable nonReentrant {
require(msg.value >= mintPrice * quantity, "Insufficient payment");
require(minted[msg.sender] + quantity <= MAX_PER_WALLET, "Exceeds limit");
// Effects ПЕРШИМИ
minted[msg.sender] += quantity;
// Interactions ПІСЛЯ
for (uint256 i = 0; i < quantity; i++) {
_safeMint(msg.sender, ++_tokenCounter);
}
}
Randomness: VRF vs blockhash
On-chain randomness для reveal — часта вразливість. block.timestamp, blockhash, prevrandao — все маніпулюється.
// ВРАЗЛИВО: miner/validator може маніпулювати blockhash
function revealTokens() external onlyOwner {
for (uint256 i = 1; i <= totalSupply; i++) {
uint256 randomSeed = uint256(keccak256(abi.encodePacked(
blockhash(block.number - 1),
i,
block.timestamp
)));
tokenTraits[i] = _assignTraits(randomSeed);
}
}
Validator може викликати revealTokens тільки у блоках, де blockhash дає вигідні traits. Це не теоретичний ризик — на крупних колекціях це траплялося.
Правильне рішення: Chainlink VRF v2.
contract SecureNFT is VRFConsumerBaseV2 {
VRFCoordinatorV2Interface private vrfCoordinator;
uint64 private subscriptionId;
bytes32 private keyHash;
uint256 public randomWord; // отримано від Chainlink
bool public revealed;
function requestReveal() external onlyOwner {
require(!revealed, "Already revealed");
vrfCoordinator.requestRandomWords(
keyHash,
subscriptionId,
3, // confirmations
100000, // callbackGasLimit
1 // numWords
);
}
function fulfillRandomWords(uint256, uint256[] memory randomWords) internal override {
randomWord = randomWords[0];
revealed = true;
emit Revealed(randomWord);
}
function getTraits(uint256 tokenId) public view returns (Traits memory) {
require(revealed, "Not revealed");
uint256 seed = uint256(keccak256(abi.encodePacked(randomWord, tokenId)));
return _assignTraits(seed);
}
}
Mint логіка: whitelist, ліміти, timing
// Перевіряємо в аудиті:
// 1. Merkle whitelist — коректність подвійного хешування
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, maxAmount))));
// НЕ: keccak256(abi.encodePacked(msg.sender)) — вразливо до hash collision
// 2. Захист від перевищення supply
require(totalSupply() + quantity <= MAX_SUPPLY, "Exceeds supply");
// Немає off-by-one помилок?
// 3. Team mint не перевищує оголошене
// Перевіряємо що team allocation реально обмежена
// 4. Front-running захист для whitelist
// Merkle proof не містить amount — не можна скопіювати proof іншому адресу?
// Leaf має включати msg.sender!
Механізм Royalty: ERC-2981
// Коректна реалізація ERC-2981
contract NFTWithRoyalty is ERC721, ERC2981 {
constructor() {
// Встановлюємо royalty: 5% для owner
_setDefaultRoyalty(msg.sender, 500); // 500 basis points = 5%
}
// royaltyInfo має повертати коректні значення
// Перевіряємо: не перевищує 100%, коректний receiver
function royaltyInfo(uint256, uint256 salePrice)
public view override returns (address receiver, uint256 royaltyAmount)
{
return super.royaltyInfo(0, salePrice);
}
// supportsInterface має включати ERC2981
function supportsInterface(bytes4 interfaceId)
public view override(ERC721, ERC2981) returns (bool) {
return super.supportsInterface(interfaceId);
}
}
В аудиті перевіряємо: royalty receiver не дорівнює address(0), процент не аномально високий, немає можливості змінити receiver без governance.
Функція withdraw
Проста, але критична: хто може вивести ETH із контракту?
// Перевіряємо в аудиті:
// 1. onlyOwner або multisig?
function withdraw() external onlyOwner {
payable(owner()).transfer(address(this).balance);
}
// 2. Немає ли pull payment вразливостей?
// 3. ETH не може застрять у контракті при невдалому transfer?
// Використовуємо call замість transfer:
(bool success, ) = payable(owner()).call{value: address(this).balance}("");
require(success, "Transfer failed");
// 4. Немає ли можливості drain перед reveal/sale через exploit?
Типичні findings по severity
| Severity | Приклад вразливості | Частота |
|---|---|---|
| Critical | Reentrancy в mint дозволяє mint більше supply | Рідко |
| High | Манипульовний randomness для reveal | Часто |
| High | Merkle leaf без msg.sender — proof можна украсти | Середньо |
| Medium | Withdraw без nonReentrant | Часто |
| Medium | totalSupply overflow при великій кількості mint | Рідко |
| Low | Відсутність подій для критичних функцій | Дуже часто |
| Informational | Gas оптимізація (ERC721A vs ERC721) | Завжди |
ERC-721A: специфіка аудиту
Багато колекцій використовують ERC-721A (оптимізовану версію Azuki для batch mint). Специфічні перевірки:
- Коректність
_startTokenId()— за замовчуванням 0, але деякі проекти хочуть 1 -
_nextTokenId()коректно ініціалізований - Batch transfer не порушує ownership маппинг
-
tokensOfOwnerне перевищує gas limit для cold wallets з великою кількістю токенів
Процес аудиту
Статичний аналіз. Запуск Slither — автоматичне виявлення стандартних паттернів (reentrancy, integer overflow, unprotected функції). Не замінює ручний аудит, але відсіває базові проблеми.
slither contracts/NFTCollection.sol \
--solc-remaps "@openzeppelin=node_modules/@openzeppelin" \
--checklist \
--markdown-root .
Ручний review. Перевірка business logic: відповідає ли код whitepaper та оголошеній механіці. Автоматика не зрозуміє, що maxPerWallet = 100 при totalSupply = 10000 порушує оголошену "fair launch" механіку.
Fuzz тестування. Foundry fuzzing для mint логіки та edge cases:
function testFuzz_MintDoesNotExceedSupply(uint256 quantity) public {
quantity = bound(quantity, 1, 100);
vm.deal(user, 100 ether);
vm.prank(user);
nft.mint{value: 0.1 ether * quantity}(quantity);
assertLe(nft.totalSupply(), nft.MAX_SUPPLY());
}
Термін аудиту NFT-контракту: 1–2 тижні для стандартної коллекції. З кастомною механікою (staking, breeding, on-chain game logic) — 2–4 тижні.







