Разработка NFT с royalties
В 2022-2023 годах маркетплейсы начали делать роялти опциональными — Blur предлагал нулевые комиссии, часть аудитории OpenSea перешла туда. Создатели коллекций потеряли миллионы. Это привело к двум лагерям: те, кто ставит on-chain enforcement роялти, и те, кто полагается на goodwill маркетплейсов. Выбор между ними — не технический, а продуктовый. Мы реализуем оба подхода.
ERC-2981 как базовый стандарт
ERC-2981 — это сигнальный стандарт. Контракт объявляет royaltyInfo(tokenId, salePrice), маркетплейс читает и (опционально) выплачивает. Blur может игнорировать. OpenSea чтит. Magic Eden — depends.
Реализация через OpenZeppelin занимает 10 строк:
import "@openzeppelin/contracts/token/common/ERC2981.sol";
contract MyCollection is ERC721, ERC2981 {
constructor(address royaltyReceiver) ERC721("Collection", "COL") {
_setDefaultRoyalty(royaltyReceiver, 750); // 7.5%
}
function supportsInterface(bytes4 interfaceId)
public view override(ERC721, ERC2981) returns (bool) {
return super.supportsInterface(interfaceId);
}
}
Без supportsInterface override маркетплейс не увидит ERC-2981 поддержку при ERC-165 проверке.
Operator Filter: on-chain enforcement
Если роялти важны коммерчески — нужен operator filter. Идея: контракт проверяет каждый transferFrom и safeTransferFrom, разрешает transfer только через апрувнутые маркетплейсы, которые честно выплачивают роялти.
OpenSea предложил OperatorFilterRegistry в 2022 году:
import {DefaultOperatorFilterer} from "operator-filter-registry/src/DefaultOperatorFilterer.sol";
contract MyCollection is ERC721, ERC2981, DefaultOperatorFilterer {
function transferFrom(address from, address to, uint256 tokenId)
public override onlyAllowedOperator(from) {
super.transferFrom(from, to, tokenId);
}
function safeTransferFrom(address from, address to, uint256 tokenId)
public override onlyAllowedOperatorApproval(from) {
super.safeTransferFrom(from, to, tokenId);
}
}
onlyAllowedOperator проверяет адрес оператора в OperatorFilterRegistry. Blur был изначально заблокирован, потом добавлен после переговоров.
Компромисс: operator filter защищает роялти, но ограничивает ликвидность — пользователи не могут торговать на неодобренных платформах. Для некоторых коллекций это неприемлемо.
Собственная royalty enforcement логика
Независимость от OpenSea реестра — через кастомную логику. Подход: разрешаем transfer только если он инициирован через whitelist контрактов (маркетплейсы, которые явно интегрировали наш роялти механизм), или если это wallet-to-wallet transfer (не через маркетплейс).
mapping(address => bool) public approvedMarketplaces;
function _beforeTokenTransfer(address from, address to, uint256 tokenId)
internal override {
// Разрешаем прямые трансферы (не через маркетплейс)
if (from == tx.origin || to == tx.origin) return;
// Проверяем, что маркетплейс одобрен
require(approvedMarketplaces[msg.sender], "Marketplace not approved");
}
Это менее гибко, но независимо от внешних реестров.
Splitter для команд
Если роялти делятся между несколькими адресами, ставим receiver в ERC-2981 на PaymentSplitter:
address[] memory payees = [founder, artist, treasury];
uint256[] memory shares = [50, 30, 20];
PaymentSplitter splitter = new PaymentSplitter(payees, shares);
_setDefaultRoyalty(address(splitter), 500); // 5% роялти на сплиттер
Каждый получатель вызывает splitter.release(token) чтобы забрать накопленные средства. Pull pattern — нет риска reentrancy при автоматической рассылке.
Типичные ошибки
Забытый supportsInterface — маркетплейс не видит ERC-2981. Роялти на нулевой адрес при address(0) receiver — выплаты уходят в никуда. Слишком высокие роялти (>10%) снижают торговый объём. Отсутствие возможности обновить receiver — создатель не может сменить адрес кошелька.
Для обновляемого receiver добавляем updateDefaultRoyalty() с onlyOwner:
function updateDefaultRoyalty(address receiver, uint96 feeNumerator)
external onlyOwner {
_setDefaultRoyalty(receiver, feeNumerator);
}
Ориентиры по срокам
NFT контракт с ERC-2981 роялти и PaymentSplitter — 2-3 дня. С operator filter и кастомной enforcement логикой — 3-4 дня.







