Розробка стейкінгу токенів з NFT-наградами
Стейкінг з NFT-наградами — це гібрид двох механік: liquidity lock (користувач блокує токени, отримує yield) та NFT-дистрибуція як додатковий стимул. Проста реалізація «застейкай X токенів, отримай NFT» працює, але швидко теряє привабливість. Цікава механіка будується на динамічних NFT, які еволюціонують разом зі стейкінгом.
Базова архітектура стейкінг-контракту
Стейкінг-контракт реалізує три речі: lock токенів, розрахунок accumulated rewards, mint/upgrade NFT по досягненню milestones.
contract TokenStakingWithNFT {
IERC20 public immutable stakingToken;
IRewardNFT public immutable rewardNFT;
struct StakeInfo {
uint256 amount;
uint256 stakedAt;
uint256 rewardDebt; // для коректного розрахунку rewards
uint256 nftTokenId; // 0 якщо NFT ще не виданий
uint8 nftTier; // поточний тир NFT (0–4)
}
mapping(address => StakeInfo) public stakes;
uint256 public accRewardPerShare; // накопилений reward per share (scaled 1e12)
uint256 public lastRewardBlock;
uint256 public rewardPerBlock;
uint256 public totalStaked;
// Milestones для NFT upgrade (у днях стейкінгу)
uint256[] public nftTierThresholds = [7, 30, 90, 180, 365];
function stake(uint256 amount) external nonReentrant {
_updatePool();
StakeInfo storage info = stakes[msg.sender];
// Виплачуємо pending rewards перед зміною стейку
if (info.amount > 0) {
uint256 pending = info.amount * accRewardPerShare / 1e12 - info.rewardDebt;
if (pending > 0) _distributeReward(msg.sender, pending);
}
stakingToken.safeTransferFrom(msg.sender, address(this), amount);
info.amount += amount;
if (info.stakedAt == 0) {
info.stakedAt = block.timestamp;
// Mint початкового NFT (Tier 0) при першому стейку
info.nftTokenId = rewardNFT.mint(msg.sender, 0);
}
totalStaked += amount;
info.rewardDebt = info.amount * accRewardPerShare / 1e12;
emit Staked(msg.sender, amount);
}
function checkAndUpgradeNFT() external {
StakeInfo storage info = stakes[msg.sender];
require(info.amount > 0, "Not staking");
uint256 stakingDays = (block.timestamp - info.stakedAt) / 1 days;
uint8 newTier = _calculateTier(stakingDays);
if (newTier > info.nftTier) {
info.nftTier = newTier;
rewardNFT.upgrade(info.nftTokenId, newTier);
emit NFTUpgraded(msg.sender, info.nftTokenId, newTier);
}
}
function _calculateTier(uint256 days_) internal view returns (uint8) {
for (uint8 i = uint8(nftTierThresholds.length); i > 0; i--) {
if (days_ >= nftTierThresholds[i - 1]) return i;
}
return 0;
}
}
Динамічні NFT через on-chain metadata
ERC-721 з динамічним tokenURI — NFT змінює зображення та атрибути при upgrade. Два підходи: off-chain metadata на IPFS (швидко, але потребує оновлення при upgrade) та fully on-chain SVG (дорожче по газу, але повністю децентралізовано).
contract RewardNFT is ERC721, Ownable {
mapping(uint256 => uint8) public tokenTier;
mapping(uint8 => string) public tierImageURI; // IPFS CID для кожного тиру
address public stakingContract;
function mint(address to, uint8 initialTier) external returns (uint256) {
require(msg.sender == stakingContract, "Only staking contract");
uint256 tokenId = ++_tokenCounter;
_safeMint(to, tokenId);
tokenTier[tokenId] = initialTier;
return tokenId;
}
function upgrade(uint256 tokenId, uint8 newTier) external {
require(msg.sender == stakingContract, "Only staking contract");
require(newTier > tokenTier[tokenId], "Cannot downgrade");
tokenTier[tokenId] = newTier;
emit TierUpgraded(tokenId, newTier);
}
function tokenURI(uint256 tokenId) public view override returns (string memory) {
require(_exists(tokenId), "Token does not exist");
uint8 tier = tokenTier[tokenId];
string memory imageURI = tierImageURI[tier];
// Генеруємо metadata on-the-fly
return string(abi.encodePacked(
'data:application/json;base64,',
Base64.encode(bytes(abi.encodePacked(
'{"name":"Staker NFT Tier ', Strings.toString(tier), '",',
'"description":"Reward NFT for loyal stakers",',
'"image":"', imageURI, '",',
'"attributes":[{"trait_type":"Tier","value":', Strings.toString(tier), '},',
'{"trait_type":"Tier Name","value":"', _tierName(tier), '"}]}'
)))
));
}
function _tierName(uint8 tier) internal pure returns (string memory) {
if (tier == 0) return "Bronze";
if (tier == 1) return "Silver";
if (tier == 2) return "Gold";
if (tier == 3) return "Platinum";
return "Diamond";
}
}
Важливий нюанс: soulbound або transferable
Якщо NFT transferable — виникає проблема: хтось може купити NFT Diamond tier на вторинному ринку без стейкінгу. Якщо NFT повинен відображати саме стейкінг, потрібно soulbound (ERC-5192) або привязка до адреси у контракті стейкінгу.
// ERC-5192: мінімальний soulbound інтерфейс
function locked(uint256 tokenId) external view returns (bool) {
return true; // всі токени locked
}
function _beforeTokenTransfer(address from, address to, uint256 tokenId, uint256 batchSize)
internal override {
// Дозволяємо тільки mint (from == address(0)) та burn (to == address(0))
require(from == address(0) || to == address(0), "Soulbound: non-transferable");
super._beforeTokenTransfer(from, to, tokenId, batchSize);
}
Reward механіка: токени vs NFT boost
NFT тир може давати не тільки візуальний апгрейд, але й boost до reward rate:
| Тир | Днів у стейкінгу | Базовий boost | Доп. привілегії |
|---|---|---|---|
| Bronze (0) | 7+ | +0% | Базовий NFT |
| Silver (1) | 30+ | +10% | Доступ до закритого Discord |
| Gold (2) | 90+ | +25% | Whitelist на наступний NFT drop |
| Platinum (3) | 180+ | +50% | Governance множник x2 |
| Diamond (4) | 365+ | +100% | Фізичний мерч, IRL доступ |
function _getUserMultiplier(address user) internal view returns (uint256) {
uint8 tier = rewardNFT.tokenTier(stakes[user].nftTokenId);
// basis points: 10000 = 1x, 20000 = 2x
uint256[5] memory multipliers = [uint256(10000), 11000, 12500, 15000, 20000];
return multipliers[tier];
}
function pendingReward(address user) public view returns (uint256) {
StakeInfo storage info = stakes[user];
uint256 acc = accRewardPerShare;
if (block.number > lastRewardBlock && totalStaked > 0) {
uint256 blocks = block.number - lastRewardBlock;
acc += blocks * rewardPerBlock * 1e12 / totalStaked;
}
uint256 baseReward = info.amount * acc / 1e12 - info.rewardDebt;
uint256 multiplier = _getUserMultiplier(user);
return baseReward * multiplier / 10000;
}
Early unstake penalty та lock periods
Механіка penalty стимулює довгостроковий стейкінг та захищає від dump після отримання NFT:
uint256 public constant MIN_LOCK_PERIOD = 7 days;
uint256 public constant PENALTY_RATE = 1000; // 10% у basis points
function unstake(uint256 amount) external nonReentrant {
StakeInfo storage info = stakes[msg.sender];
require(info.amount >= amount, "Insufficient stake");
_updatePool();
uint256 pending = info.amount * accRewardPerShare / 1e12 - info.rewardDebt;
if (pending > 0) _distributeReward(msg.sender, pending);
uint256 actualAmount = amount;
// Penalty при ранньому выході
if (block.timestamp < info.stakedAt + MIN_LOCK_PERIOD) {
uint256 penalty = amount * PENALTY_RATE / 10000;
actualAmount = amount - penalty;
stakingToken.safeTransfer(penaltyCollector, penalty);
}
info.amount -= amount;
totalStaked -= amount;
stakingToken.safeTransfer(msg.sender, actualAmount);
// Якщо повністю вийшов — lock або burn NFT
if (info.amount == 0) {
rewardNFT.lockOnUnstake(info.nftTokenId);
}
info.rewardDebt = info.amount * accRewardPerShare / 1e12;
}
Безпека
Два головних вектора атак на стейкінг:
Reentrancy: всі функції, що змінюють state та роблять зовнішні вызови, повинні мати nonReentrant. Особливо stake, unstake, claim.
Overflow у reward розрахунку: накопилений accRewardPerShare може переповнити uint256 при великій кількості блоків або великому rewardPerBlock. Перевіряйте масштабування. MasterChef V2 від SushiSwap — хорошого референсна реалізація.
Контракт обов'язковий до аудиту перед запуском — стейкінг-контракти тримають кошти користувачів постійно та є приоритетними цілями для атак.







