LPsLux Proposals
LRC Standards
LP-3210

NFT Staking Standard

Final

Standard for staking NFTs to earn rewards on Lux Network

Category
LRC
Created
2025-01-23

Abstract

This LP defines a standard for NFT staking protocols on the Lux Network, enabling NFT holders to stake their tokens to earn rewards. The standard supports both LRC-721 and LRC-1155 tokens, various reward mechanisms, boost systems, and flexible staking periods while maintaining composability with other DeFi protocols.

Motivation

NFT staking standards enable:

  1. Utility for NFTs: Generate yield from NFT holdings
  2. Ecosystem Incentives: Reward long-term holders
  3. Liquidity Retention: Reduce selling pressure
  4. Governance Rights: Staking for voting power
  5. Composability: Integration with DeFi protocols

Specification

Core NFT Staking Interface

interface ILuxNFTStaking {
    // Staking information
    struct StakeInfo {
        address collection;
        uint256 tokenId;
        address owner;
        uint256 stakedAt;
        uint256 lockEndTime;
        uint256 rewardDebt;
        uint256 accumulatedRewards;
    }
    
    struct PoolInfo {
        address collection;          // NFT collection address
        address rewardToken;         // Reward token address
        uint256 rewardPerBlock;      // Base reward per block
        uint256 totalStaked;         // Total NFTs staked
        uint256 accRewardPerNFT;     // Accumulated rewards per NFT
        uint256 lastRewardBlock;     // Last block rewards calculated
        bool isActive;               // Pool status
        bool isLRC1155;             // True for LRC-1155, false for LRC-721
    }
    
    // Events
    event PoolCreated(
        uint256 indexed poolId,
        address indexed collection,
        address rewardToken,
        uint256 rewardPerBlock
    );
    
    event Staked(
        uint256 indexed poolId,
        address indexed user,
        uint256 indexed tokenId,
        uint256 amount // 1 for LRC-721, variable for LRC-1155
    );
    
    event Unstaked(
        uint256 indexed poolId,
        address indexed user,
        uint256 indexed tokenId,
        uint256 amount
    );
    
    event RewardClaimed(
        uint256 indexed poolId,
        address indexed user,
        uint256 amount
    );
    
    event EmergencyWithdraw(
        uint256 indexed poolId,
        address indexed user,
        uint256[] tokenIds
    );
    
    // Core functions
    function createPool(
        address collection,
        address rewardToken,
        uint256 rewardPerBlock,
        bool isLRC1155
    ) external returns (uint256 poolId);
    
    function stake(
        uint256 poolId,
        uint256[] calldata tokenIds,
        uint256[] calldata amounts // For LRC-1155
    ) external;
    
    function unstake(
        uint256 poolId,
        uint256[] calldata tokenIds,
        uint256[] calldata amounts
    ) external;
    
    function claimRewards(uint256 poolId) external;
    
    function emergencyWithdraw(uint256 poolId) external;
    
    // View functions
    function pendingRewards(uint256 poolId, address user) external view returns (uint256);
    function getUserStakes(uint256 poolId, address user) external view returns (StakeInfo[] memory);
    function getPoolInfo(uint256 poolId) external view returns (PoolInfo memory);
}

Boost System Extension

interface INFTStakingBoost is ILuxNFTStaking {
    struct BoostInfo {
        uint256 baseMultiplier;     // 100 = 1x
        uint256 rarityMultiplier;   // Based on NFT rarity
        uint256 timeMultiplier;     // Based on staking duration
        uint256 comboMultiplier;    // For staking sets
    }
    
    event BoostApplied(
        uint256 indexed poolId,
        address indexed user,
        uint256 tokenId,
        uint256 boostMultiplier
    );
    
    event ComboActivated(
        uint256 indexed poolId,
        address indexed user,
        uint256[] tokenIds,
        uint256 comboMultiplier
    );
    
    // Rarity-based boost
    function setRarityBoost(
        uint256 poolId,
        uint256[] calldata rarityTiers,
        uint256[] calldata multipliers
    ) external;
    
    function getTokenRarity(
        address collection,
        uint256 tokenId
    ) external view returns (uint256);
    
    // Time-based boost
    function setTimeBoost(
        uint256 poolId,
        uint256[] calldata durations,
        uint256[] calldata multipliers
    ) external;
    
    // Combo boost for sets
    function setComboRequirements(
        uint256 poolId,
        uint256[][] calldata requiredTokenIds,
        uint256[] calldata comboMultipliers
    ) external;
    
    function checkCombo(
        uint256 poolId,
        address user
    ) external view returns (bool hasCombo, uint256 multiplier);
    
    function getUserBoost(
        uint256 poolId,
        address user
    ) external view returns (uint256 totalMultiplier);
}

Lock Period Extension

interface INFTStakingLock is ILuxNFTStaking {
    struct LockOption {
        uint256 duration;
        uint256 multiplier; // Reward multiplier
        bool earlyUnstakePenalty;
        uint256 penaltyPercentage;
    }
    
    event LockOptionSet(
        uint256 indexed poolId,
        uint256 indexed lockId,
        uint256 duration,
        uint256 multiplier
    );
    
    event EarlyUnstake(
        uint256 indexed poolId,
        address indexed user,
        uint256 tokenId,
        uint256 penalty
    );
    
    function setLockOptions(
        uint256 poolId,
        LockOption[] calldata options
    ) external;
    
    function stakeWithLock(
        uint256 poolId,
        uint256[] calldata tokenIds,
        uint256 lockOptionId
    ) external;
    
    function getLockOptions(
        uint256 poolId
    ) external view returns (LockOption[] memory);
    
    function getUnlockTime(
        uint256 poolId,
        address user,
        uint256 tokenId
    ) external view returns (uint256);
}

Multi-Reward Extension

interface INFTStakingMultiReward is ILuxNFTStaking {
    struct RewardToken {
        address token;
        uint256 rewardPerBlock;
        uint256 accRewardPerNFT;
        uint256 lastRewardBlock;
        uint256 totalDistributed;
    }
    
    event RewardTokenAdded(
        uint256 indexed poolId,
        address indexed token,
        uint256 rewardPerBlock
    );
    
    event MultiRewardClaimed(
        uint256 indexed poolId,
        address indexed user,
        address[] tokens,
        uint256[] amounts
    );
    
    function addRewardToken(
        uint256 poolId,
        address token,
        uint256 rewardPerBlock
    ) external;
    
    function updateRewardRate(
        uint256 poolId,
        address token,
        uint256 newRewardPerBlock
    ) external;
    
    function pendingMultiRewards(
        uint256 poolId,
        address user
    ) external view returns (
        address[] memory tokens,
        uint256[] memory amounts
    );
    
    function claimAllRewards(uint256 poolId) external;
}

Delegation Extension

interface INFTStakingDelegation is ILuxNFTStaking {
    struct Delegation {
        address delegatee;
        uint256 shares;
        uint256 lastUpdate;
    }
    
    event RewardsDelegated(
        uint256 indexed poolId,
        address indexed delegator,
        address indexed delegatee,
        uint256 shares
    );
    
    event DelegationRevoked(
        uint256 indexed poolId,
        address indexed delegator,
        address indexed delegatee
    );
    
    function delegateRewards(
        uint256 poolId,
        address delegatee,
        uint256 sharePercentage // 100 = 100%
    ) external;
    
    function revokeDelegation(
        uint256 poolId,
        address delegatee
    ) external;
    
    function getDelegationInfo(
        uint256 poolId,
        address delegator
    ) external view returns (Delegation[] memory);
}

Rationale

Support for Both NFT Standards

Supporting LRC-721 and LRC-1155 ensures:

  • Maximum compatibility
  • Flexibility for different NFT types
  • Efficient handling of both standards

Boost Mechanisms

Multiple boost types provide:

  • Incentives for rare NFTs
  • Rewards for long-term staking
  • Bonuses for complete sets
  • Gamification elements

Flexible Lock Periods

Lock options enable:

  • Higher rewards for commitment
  • Reduced selling pressure
  • Predictable staking periods
  • Optional early withdrawal

Backwards Compatibility

This standard is compatible with:

  • LRC-721 NFT standard
  • LRC-1155 multi-token standard
  • LRC-20 reward tokens
  • Existing staking patterns

Test Cases

Basic Staking Operations

contract NFTStakingTest {
    ILuxNFTStaking staking;
    IERC721 nftCollection;
    IERC20 rewardToken;
    
    function testCreatePoolAndStake() public {
        // Create staking pool
        uint256 poolId = staking.createPool(
            address(nftCollection),
            address(rewardToken),
            100 * 10**18, // 100 tokens per block
            false // LRC-721
        );
        
        // Mint and approve NFT
        nftCollection.mint(address(this), 1);
        nftCollection.approve(address(staking), 1);
        
        // Stake NFT
        uint256[] memory tokenIds = new uint256[](1);
        tokenIds[0] = 1;
        uint256[] memory amounts; // Empty for LRC-721
        
        staking.stake(poolId, tokenIds, amounts);
        
        // Check stake
        ILuxNFTStaking.StakeInfo[] memory stakes = staking.getUserStakes(
            poolId,
            address(this)
        );
        
        assertEq(stakes.length, 1);
        assertEq(stakes[0].tokenId, 1);
        assertEq(nftCollection.ownerOf(1), address(staking));
    }
    
    function testRewardCalculation() public {
        // Setup and stake
        uint256 poolId = setupPool();
        stakeNFT(poolId, 1);
        
        // Mine 100 blocks
        vm.roll(block.number + 100);
        
        // Check pending rewards
        uint256 pending = staking.pendingRewards(poolId, address(this));
        
        // Should have 100 blocks * 100 tokens/block = 10,000 tokens
        assertEq(pending, 10000 * 10**18);
        
        // Claim rewards
        uint256 balanceBefore = rewardToken.balanceOf(address(this));
        staking.claimRewards(poolId);
        uint256 balanceAfter = rewardToken.balanceOf(address(this));
        
        assertEq(balanceAfter - balanceBefore, pending);
    }
}

Boost System Testing

function testRarityBoost() public {
    INFTStakingBoost boostStaking = INFTStakingBoost(address(staking));
    
    uint256 poolId = setupPool();
    
    // Set rarity boosts: Common = 1x, Rare = 1.5x, Legendary = 2x
    uint256[] memory tiers = new uint256[](3);
    tiers[0] = 0; // Common
    tiers[1] = 1; // Rare  
    tiers[2] = 2; // Legendary
    
    uint256[] memory multipliers = new uint256[](3);
    multipliers[0] = 100;  // 1x
    multipliers[1] = 150;  // 1.5x
    multipliers[2] = 200;  // 2x
    
    boostStaking.setRarityBoost(poolId, tiers, multipliers);
    
    // Stake legendary NFT
    stakeNFT(poolId, 1); // Assume tokenId 1 is legendary
    
    // Check boost
    uint256 boost = boostStaking.getUserBoost(poolId, address(this));
    assertEq(boost, 200); // 2x multiplier
}

function testLockBonus() public {
    INFTStakingLock lockStaking = INFTStakingLock(address(staking));
    
    uint256 poolId = setupPool();
    
    // Set lock options
    INFTStakingLock.LockOption[] memory options = new INFTStakingLock.LockOption[](3);
    options[0] = INFTStakingLock.LockOption(0, 100, false, 0);        // No lock, 1x
    options[1] = INFTStakingLock.LockOption(30 days, 150, true, 50);  // 30 days, 1.5x
    options[2] = INFTStakingLock.LockOption(90 days, 200, true, 50);  // 90 days, 2x
    
    lockStaking.setLockOptions(poolId, options);
    
    // Stake with 90 day lock
    uint256[] memory tokenIds = new uint256[](1);
    tokenIds[0] = 1;
    
    nftCollection.approve(address(staking), 1);
    lockStaking.stakeWithLock(poolId, tokenIds, 2);
    
    // Try early unstake (should fail)
    vm.expectRevert("Still locked");
    staking.unstake(poolId, tokenIds, new uint256[](0));
    
    // Fast forward 90 days
    vm.warp(block.timestamp + 90 days);
    
    // Now unstake should work
    staking.unstake(poolId, tokenIds, new uint256[](0));
}

Implementation

Reference Implementation

Location: ~/work/lux/standard/src/nft-staking/

Files:

  • LuxNFTStaking.sol - Core implementation
  • LuxNFTStakingBoost.sol - Boost system extension
  • LuxNFTStakingLock.sol - Lock period extension
  • LuxNFTStakingMultiReward.sol - Multi-reward extension
  • LuxNFTStakingDelegation.sol - Delegation extension

Deployment:

cd ~/work/lux/standard
forge build

# Deploy to C-Chain
forge script script/DeployNFTStaking.s.sol:DeployNFTStaking \
  --rpc-url https://api.avax.network/ext/bc/C/rpc \
  --broadcast

Testing

Foundry Test Suite: test/nft-staking/

cd ~/work/lux/standard

# Run all NFT staking tests
forge test --match-path test/nft-staking/\* -vvv

# Run specific test
forge test --match NFTStakingTest --match-contract -vvv

# Gas reports
forge test --match-path test/nft-staking/\* --gas-report

# Coverage
forge coverage --match-path test/nft-staking/\*

Test Cases (see /test/nft-staking/LuxNFTStaking.t.sol):

  • testCreatePoolAndStake() - Pool creation and basic staking
  • testRewardCalculation() - Accurate reward accrual
  • testRarityBoost() - Rarity multiplier application
  • testLockBonus() - Lock period reward multipliers
  • testEmergencyWithdraw() - Emergency withdrawal without claims
  • testBoostSystem() - Comprehensive boost testing
  • testMultiReward() - Multiple reward token handling
  • testDelegation() - Reward delegation system

Gas Benchmarks (Apple M1 Max):

OperationGas CostTime
createPool~80,000~2.1ms
stake (1 NFT)~95,000~2.4ms
unstake (1 NFT)~75,000~1.9ms
claimRewards~65,000~1.6ms
setRarityBoost~120,000~3.0ms

Contract Verification

Etherscan/Sourcify:

forge verify-contract \
  --chain-id 43114 \
  --watch 0x<NFT_STAKING_ADDRESS> \
  src/nft-staking/LuxNFTStaking.sol:LuxNFTStaking

Reference Implementation

contract LuxNFTStaking is ILuxNFTStaking, INFTStakingBoost, ReentrancyGuard, Ownable {
    using SafeERC20 for IERC20;
    
    uint256 public nextPoolId = 1;
    mapping(uint256 => PoolInfo) public pools;
    mapping(uint256 => mapping(address => StakeInfo[])) public userStakes;
    mapping(uint256 => mapping(uint256 => address)) public tokenOwners; // poolId => tokenId => owner
    mapping(uint256 => mapping(address => uint256)) public userRewardDebt;
    
    // Boost system
    mapping(uint256 => mapping(uint256 => uint256)) public rarityMultipliers; // poolId => rarity => multiplier
    mapping(address => mapping(uint256 => uint256)) public tokenRarities; // collection => tokenId => rarity
    
    modifier validPool(uint256 poolId) {
        require(pools[poolId].collection != address(0), "Invalid pool");
        require(pools[poolId].isActive, "Pool not active");
        _;
    }
    
    function createPool(
        address collection,
        address rewardToken,
        uint256 rewardPerBlock,
        bool isLRC1155
    ) external override onlyOwner returns (uint256 poolId) {
        require(collection != address(0), "Invalid collection");
        require(rewardToken != address(0), "Invalid reward token");
        
        poolId = nextPoolId++;
        
        pools[poolId] = PoolInfo({
            collection: collection,
            rewardToken: rewardToken,
            rewardPerBlock: rewardPerBlock,
            totalStaked: 0,
            accRewardPerNFT: 0,
            lastRewardBlock: block.number,
            isActive: true,
            isLRC1155: isLRC1155
        });
        
        emit PoolCreated(poolId, collection, rewardToken, rewardPerBlock);
    }
    
    function stake(
        uint256 poolId,
        uint256[] calldata tokenIds,
        uint256[] calldata amounts
    ) external override nonReentrant validPool(poolId) {
        PoolInfo storage pool = pools[poolId];
        
        // Update pool rewards
        updatePool(poolId);
        
        // Claim pending rewards first
        _claimRewards(poolId, msg.sender);
        
        uint256 totalAmount = 0;
        
        for (uint256 i = 0; i < tokenIds.length; i++) {
            uint256 tokenId = tokenIds[i];
            uint256 amount = pool.isLRC1155 ? amounts[i] : 1;
            
            require(amount > 0, "Invalid amount");
            
            // Transfer NFT
            if (pool.isLRC1155) {
                IERC1155(pool.collection).safeTransferFrom(
                    msg.sender,
                    address(this),
                    tokenId,
                    amount,
                    ""
                );
            } else {
                IERC721(pool.collection).safeTransferFrom(
                    msg.sender,
                    address(this),
                    tokenId
                );
            }
            
            // Record stake
            userStakes[poolId][msg.sender].push(StakeInfo({
                collection: pool.collection,
                tokenId: tokenId,
                owner: msg.sender,
                stakedAt: block.timestamp,
                lockEndTime: 0,
                rewardDebt: 0,
                accumulatedRewards: 0
            }));
            
            tokenOwners[poolId][tokenId] = msg.sender;
            totalAmount += amount;
            
            emit Staked(poolId, msg.sender, tokenId, amount);
        }
        
        pool.totalStaked += totalAmount;
        
        // Update user reward debt
        userRewardDebt[poolId][msg.sender] = pool.accRewardPerNFT * getUserStakedAmount(poolId, msg.sender);
    }
    
    function unstake(
        uint256 poolId,
        uint256[] calldata tokenIds,
        uint256[] calldata amounts
    ) external override nonReentrant validPool(poolId) {
        PoolInfo storage pool = pools[poolId];
        
        // Update pool rewards
        updatePool(poolId);
        
        // Claim pending rewards first
        _claimRewards(poolId, msg.sender);
        
        uint256 totalAmount = 0;
        
        for (uint256 i = 0; i < tokenIds.length; i++) {
            uint256 tokenId = tokenIds[i];
            require(tokenOwners[poolId][tokenId] == msg.sender, "Not owner");
            
            uint256 amount = pool.isLRC1155 ? amounts[i] : 1;
            
            // Remove stake record
            _removeStake(poolId, msg.sender, tokenId);
            delete tokenOwners[poolId][tokenId];
            
            // Transfer NFT back
            if (pool.isLRC1155) {
                IERC1155(pool.collection).safeTransferFrom(
                    address(this),
                    msg.sender,
                    tokenId,
                    amount,
                    ""
                );
            } else {
                IERC721(pool.collection).safeTransferFrom(
                    address(this),
                    msg.sender,
                    tokenId
                );
            }
            
            totalAmount += amount;
            
            emit Unstaked(poolId, msg.sender, tokenId, amount);
        }
        
        pool.totalStaked -= totalAmount;
        
        // Update user reward debt
        userRewardDebt[poolId][msg.sender] = pool.accRewardPerNFT * getUserStakedAmount(poolId, msg.sender);
    }
    
    function claimRewards(uint256 poolId) external override nonReentrant validPool(poolId) {
        updatePool(poolId);
        _claimRewards(poolId, msg.sender);
    }
    
    function _claimRewards(uint256 poolId, address user) internal {
        uint256 pending = pendingRewards(poolId, user);
        
        if (pending > 0) {
            PoolInfo storage pool = pools[poolId];
            IERC20(pool.rewardToken).safeTransfer(user, pending);
            
            emit RewardClaimed(poolId, user, pending);
        }
        
        // Update debt
        userRewardDebt[poolId][user] = pools[poolId].accRewardPerNFT * getUserStakedAmount(poolId, user);
    }
    
    function updatePool(uint256 poolId) public {
        PoolInfo storage pool = pools[poolId];
        
        if (block.number <= pool.lastRewardBlock) {
            return;
        }
        
        if (pool.totalStaked == 0) {
            pool.lastRewardBlock = block.number;
            return;
        }
        
        uint256 blocks = block.number - pool.lastRewardBlock;
        uint256 rewards = blocks * pool.rewardPerBlock;
        
        pool.accRewardPerNFT += (rewards * 1e12) / pool.totalStaked;
        pool.lastRewardBlock = block.number;
    }
    
    function pendingRewards(uint256 poolId, address user) public view override returns (uint256) {
        PoolInfo memory pool = pools[poolId];
        uint256 accRewardPerNFT = pool.accRewardPerNFT;
        
        if (block.number > pool.lastRewardBlock && pool.totalStaked > 0) {
            uint256 blocks = block.number - pool.lastRewardBlock;
            uint256 rewards = blocks * pool.rewardPerBlock;
            accRewardPerNFT += (rewards * 1e12) / pool.totalStaked;
        }
        
        uint256 userStaked = getUserStakedAmount(poolId, user);
        uint256 userBoost = getUserBoost(poolId, user);
        
        return ((userStaked * accRewardPerNFT * userBoost) / 100) / 1e12 - userRewardDebt[poolId][user];
    }
    
    function getUserStakedAmount(uint256 poolId, address user) public view returns (uint256) {
        return userStakes[poolId][user].length;
    }
    
    function getUserBoost(uint256 poolId, address user) public view override returns (uint256) {
        // Base multiplier 100 = 1x
        uint256 totalMultiplier = 100;
        
        StakeInfo[] memory stakes = userStakes[poolId][user];
        
        for (uint256 i = 0; i < stakes.length; i++) {
            uint256 rarity = tokenRarities[stakes[i].collection][stakes[i].tokenId];
            uint256 rarityMult = rarityMultipliers[poolId][rarity];
            
            if (rarityMult > totalMultiplier) {
                totalMultiplier = rarityMult;
            }
        }
        
        return totalMultiplier;
    }
    
    function _removeStake(uint256 poolId, address user, uint256 tokenId) internal {
        StakeInfo[] storage stakes = userStakes[poolId][user];
        
        for (uint256 i = 0; i < stakes.length; i++) {
            if (stakes[i].tokenId == tokenId) {
                stakes[i] = stakes[stakes.length - 1];
                stakes.pop();
                break;
            }
        }
    }
    
    // Emergency functions
    function emergencyWithdraw(uint256 poolId) external override nonReentrant {
        StakeInfo[] memory stakes = userStakes[poolId][msg.sender];
        require(stakes.length > 0, "No stakes");
        
        PoolInfo memory pool = pools[poolId];
        uint256[] memory tokenIds = new uint256[](stakes.length);
        
        for (uint256 i = 0; i < stakes.length; i++) {
            tokenIds[i] = stakes[i].tokenId;
            
            if (pool.isLRC1155) {
                IERC1155(pool.collection).safeTransferFrom(
                    address(this),
                    msg.sender,
                    stakes[i].tokenId,
                    1,
                    ""
                );
            } else {
                IERC721(pool.collection).safeTransferFrom(
                    address(this),
                    msg.sender,
                    stakes[i].tokenId
                );
            }
        }
        
        delete userStakes[poolId][msg.sender];
        pool.totalStaked -= stakes.length;
        
        emit EmergencyWithdraw(poolId, msg.sender, tokenIds);
    }
}

Security Considerations

Reentrancy Protection

All external functions must use reentrancy guards:

modifier nonReentrant() {
    require(!locked, "Reentrant call");
    locked = true;
    _;
    locked = false;
}

NFT Transfer Safety

Use safe transfer functions:

IERC721(collection).safeTransferFrom(from, to, tokenId);
// Handle onERC721Received callback

Reward Calculation Precision

Prevent rounding errors:

uint256 accRewardPerNFT; // Multiplied by 1e12 for precision

Access Control

Restrict admin functions:

modifier onlyOwner() {
    require(msg.sender == owner, "Not owner");
    _;
}

Copyright and related rights waived via CC0.```