Разработка dynamic NFT (обновляемые метаданные)
Статичный NFT — это файл с картинкой, привязанный к токену навсегда. Dynamic NFT — это токен, чьи metadata изменяются в ответ на события: прошло время, сыграла игра, изменилась цена актива, пользователь достиг нового уровня. Технически это означает, что tokenURI() возвращает разные данные в разные моменты времени. Реализаций несколько, и выбор между ними определяет степень децентрализации, стоимость обновлений и сложность разработки.
Три архитектуры dynamic NFT
On-chain metadata через SVG генерацию
Самый децентрализованный вариант: metadata и изображение генерируются прямо в смарт-контракте, никакого внешнего хранилища. tokenURI() возвращает base64-encoded JSON с base64-encoded SVG внутри.
function tokenURI(uint256 tokenId) public view override returns (string memory) {
uint256 level = playerLevel[tokenId];
string memory svg = generateSVG(level);
string memory json = Base64.encode(bytes(string(abi.encodePacked(
'{"name": "Warrior #', tokenId.toString(), '",',
'"attributes": [{"trait_type": "Level", "value": ', level.toString(), '}],',
'"image": "data:image/svg+xml;base64,', Base64.encode(bytes(svg)), '"}'
))));
return string(abi.encodePacked("data:application/json;base64,", json));
}
Преимущество: нет зависимости от IPFS, серверов, оракулов. Недостаток: сложная визуальная составляющая ограничена возможностями SVG. Подходит для gamified NFT, badges, achievement tokens.
Типичная ошибка при on-chain SVG: конкатенация строк через abi.encodePacked с пользовательскими данными. Если playerName содержит кавычки — JSON сломается. Всегда санитизируем строковые данные перед включением в JSON.
Chainlink Functions для внешних данных
Когда metadata должны отражать реальные данные — цену ETH, результат матча, погоду — нужен оракул. Chainlink Functions позволяет выполнять произвольный JavaScript off-chain и доставлять результат on-chain.
// Запрос через Chainlink Functions
function requestMetadataUpdate(uint256 tokenId) external {
FunctionsRequest.Request memory req;
req.initializeRequestForInlineJavaScript(
"const price = await fetch('https://api.coingecko.com/...')..."
);
bytes32 requestId = _sendRequest(req.encodeCBOR(), subscriptionId, gasLimit, donId);
requestToToken[requestId] = tokenId;
}
// Получение результата
function fulfillRequest(bytes32 requestId, bytes memory response, bytes memory err)
internal override {
uint256 tokenId = requestToToken[requestId];
tokenData[tokenId] = abi.decode(response, (uint256));
emit MetadataUpdate(tokenId); // EIP-4906
}
EIP-4906 — стандарт для уведомления маркетплейсов об изменении metadata. OpenSea и другие слушают событие MetadataUpdate(tokenId) и обновляют кэш. Без этого события маркетплейс показывает устаревшие данные до следующей ручной синхронизации.
Стоимость одного запроса через Chainlink Functions: 0.2-2 LINK в зависимости от вычислений. При ежедневных обновлениях для 1000 токенов — ~$150-1500/месяц. Это нужно закладывать в токеномику проекта.
Upgradeable URI с IPFS и timelock
Менее децентрализованный, но наиболее гибкий вариант: контракт хранит baseURI, который может обновляться owner. При каждом обновлении metadata загружается новая версия на IPFS, owner меняет baseURI на новый CID.
Этот подход несёт доверительный риск: owner может подменить metadata произвольно (rug the art). Для митигации — timelock на setBaseURI() с задержкой 48-72 часа. Пользователи видят запланированное изменение заранее.
Хорошая практика: хранить историю всех baseURI on-chain (append-only массив) — пользователи всегда могут проверить предыдущие версии metadata.
Игровые NFT: state машина on-chain
Для gaming проектов dynamic NFT часто реализует state machine: Egg → Baby → Adult → Legendary. Каждый transition — отдельная транзакция, проверяющая условия (прошло время, получен опыт, выполнен квест).
enum State { Egg, Baby, Adult, Legendary }
mapping(uint256 => State) public tokenState;
mapping(uint256 => uint256) public experience;
function evolve(uint256 tokenId) external {
require(ownerOf(tokenId) == msg.sender, "Not owner");
State current = tokenState[tokenId];
if (current == State.Egg) {
require(block.timestamp >= hatchTime[tokenId], "Not ready");
tokenState[tokenId] = State.Baby;
} else if (current == State.Baby) {
require(experience[tokenId] >= 1000, "Insufficient XP");
tokenState[tokenId] = State.Adult;
}
emit MetadataUpdate(tokenId); // EIP-4906
}
State хранится on-chain, изображение для каждого state — на IPFS. tokenURI() возвращает разный CID в зависимости от tokenState[tokenId].
Особенности интеграции с маркетплейсами
OpenSea кэширует metadata агрессивно. Даже с правильным MetadataUpdate событием обновление в UI может занять до 24 часов. Для проектов с критичными обновлениями (например, результаты турнира) рекомендуем:
- Добавить
forceUpdateendpoint через OpenSea API - Показывать актуальное состояние на собственном сайте через прямой RPC вызов
Blur, Gem, Reservoir — более оперативно обрабатывают EIP-4906 события, обычно в течение часа.
Процесс работы
Аналитика (1-2 дня). Определяем триггеры обновления (время, событие, внешние данные), частоту, источники данных. Выбираем архитектуру: on-chain SVG, Chainlink Functions, или upgradeable URI.
Разработка (3-6 дней). Зависит от архитектуры: on-chain SVG с простой генерацией — 3 дня; Chainlink Functions интеграция с внешним API — 5-6 дней; gaming state machine — 4-5 дней.
Тестирование (1-2 дня). Fork-тесты с Chainlink Functions на testnet (Fuji для Avalanche, Sepolia для Ethereum), проверка EIP-4906 событий на OpenSea testnet.
Стоимость рассчитывается после выбора архитектуры и определения источников данных.







