Development of NFT Staking
NFT staking is a retention mechanic: a user locks an NFT in a contract for a period and receives ERC-20 tokens as a reward. Works as a retention tool for gaming projects, as a governance power accumulator, as an emission mechanism for new tokens. The contract looks simple, but has three non-obvious places where things go wrong.
Where Staking Contracts Break
Reward Calculation: Accumulated Rounding Errors
The most common bug—drift in reward calculations due to integer division. Standard pattern:
rewardPerTokenStored += rewardRate * deltaTime / totalStaked
With totalStaked = 1000 NFT and rewardRate = 1e18 wei/sec, rewardPerTokenStored increases by 1e15 each second. Correct. But with totalStaked = 3 NFT: 1e18 / 3 = 333333333333333333—1 wei lost per second. Over a year that's ~31M seconds = ~31M wei lost. Insignificant, but with large rewardRate or small totalStaked, drift grows.
Solution: reward per token with 1e36 precision (not 1e18). Store rewardPerTokenStored in ray-like units with scaling, divide when paying. This is standard in Synthetix-derived staking contracts (StakingRewards.sol).
ERC-721 Transfer After Staking: Ownership Confusion
Naive contract: on stake, save stakers[tokenId] = msg.sender, DON'T transfer the NFT. Hope the user doesn't transfer it themselves. This breaks: user stakes, then sells the NFT on a marketplace, new owner knows nothing about staking, the old owner keeps getting rewards. Or the old owner tries to unstake someone else's NFT.
Correct approach: on stake, the NFT is physically transferred to the contract via transferFrom(msg.sender, address(this), tokenId). On unstake—returned. The contract is custodial. User can't sell a staked NFT without unstaking.
Alternative—non-custodial staking via approval + off-chain snapshot. Less common, requires different architecture with Merkle-based claim.
Reentrancy via ERC-721 onERC721Received
When safeTransferFrom is called, the contract calls onERC721Received on the receiver. If in the unstake function, the NFT is transferred back first, then stakedTokens[msg.sender] is updated—reentrancy opens via ERC-721 callback. Scenario: attacker deploys a receiver with onERC721Received that re-calls unstake. Result—double reward payout.
Checks-Effects-Interactions pattern: update all storage variables first (delete stakedTokens[msg.sender], totalStaked--, update rewards[msg.sender]), then make external calls (transfer NFT, transfer rewards). nonReentrant from OpenZeppelin—additional layer of protection.
Staking Contract Architecture
Multi-collection and Boosts
For gaming projects, often need to stake NFTs from different collections with different weights: legendary hero gives 10x rewards/day, regular hero—1x. Implementation via mapping(address collection => uint256 multiplier)—owner registers collections with weights.
Trait-based boost—NFTs with rare attributes give more rewards. Trait data stored on-chain (expensive) or accessible via Chainlink Functions / custom oracle. Pragmatic solution: backend signs (tokenId, multiplier, nonce), user passes signature on stake—contract verifies ECDSA.
Lock Periods and Bonuses
Staking with lock: user chooses a period (30/90/180 days) on stake, gets a multiplier for long-term locking. Structure:
struct StakeInfo {
address owner;
uint256 stakedAt;
uint256 lockUntil;
uint256 rewardMultiplier; // in basis points: 10000 = 1x, 15000 = 1.5x
uint256 rewardDebt;
}
Early unstake is possible but with penalty—part of accumulated rewards is burned or stays in the contract.
ERC-4626-like Approach for Fungible Staking
If staking identical NFTs (e.g., edition collection), implement a vault with ERC-4626-like mechanics: shares proportional to stake, autocompounding rewards. Less applicable for PFP with unique IDs.
Integration with Reward Token
Staking contract must mint or transfer reward token. Two approaches:
Pre-funded vault—the contract holds a fixed supply of ERC-20 for rewards. Transparent, predictable, but requires upfront funding.
Minter role—the contract has MINTER_ROLE in reward token and mints rewards on request. Flexible, but inflation isn't limited by the contract—needs external tokenomics with emission cap.
Development Process
Development (3-5 days). StakingRewards.sol adaptation + tests in Foundry. Fuzz on reward calculation with boundary totalStaked values.
Audit. Reentrancy check, overflow in reward math, privilege escalation via owner functions.
Deployment. Hardhat-deploy for reproducible deployment with parameter configuration.
Timeline Estimates
Basic staking of one collection—3-4 days. Multi-collection with trait-boosts and lock periods—1-1.5 weeks.
Cost is calculated individually.







