Skip to main content

NFT Staking System

Non-custodial NFT staking with Merkle-based epoch distribution.


Architecture Overview​

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ OFF-CHAIN (Worker) β”‚
β”‚ β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ Avalanche │───▢│ Ownership │───▢│ Holdings Snapshot β”‚ β”‚
β”‚ β”‚ Transfer β”‚ β”‚ State β”‚ β”‚ (account, tokenId, β”‚ β”‚
β”‚ β”‚ Logs β”‚ β”‚ (PG/File) β”‚ β”‚ templateId, attrs) β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ sync:ownership β”‚ derive:weights β”‚
β”‚ β–Ό β–Ό β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ Block Cursor β”‚ β”‚ Rates Engine │───▢│ Account Weights β”‚ β”‚
β”‚ β”‚ (incremental β”‚ β”‚ (template + β”‚ β”‚ [{account, weight}] β”‚ β”‚
β”‚ β”‚ + reorg β”‚ β”‚ attribute) β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ safety) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚
β”‚ β”‚ build:epoch β”‚
β”‚ β–Ό β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ Merkle Tree + Epoch Artifact β”‚ β”‚
β”‚ β”‚ {epochId, merkleRoot, totalWeight, β”‚ β”‚
β”‚ β”‚ totalReward, claims[]} β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ publish:latest
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ ON-CHAIN (NFTStaking.sol) β”‚
β”‚ β”‚
β”‚ publishEpoch(epochId, merkleRoot, totalWeight, totalReward, snapHash) β”‚
β”‚ β”‚ β”‚
β”‚ β”œβ”€β–Ά Only distributor can call β”‚
β”‚ β”œβ”€β–Ά Epoch interval must have elapsed β”‚
β”‚ β”œβ”€β–Ά epochId must equal latestEpochId + 1 β”‚
β”‚ └─▢ Deducts totalReward from rewardPool β”‚
β”‚ β”‚
β”‚ claim(epochId, weight, amount, merkleProof[]) β”‚
β”‚ β”‚ β”‚
β”‚ β”œβ”€β–Ά Verify merkleProof against stored root β”‚
β”‚ β”œβ”€β–Ά Check !claimed[epochId][msg.sender] β”‚
β”‚ └─▢ Transfer reward tokens to claimer β”‚
β”‚ β”‚
β”‚ batchClaim(epochIds[], weights[], amounts[], proofs[][]) β”‚
β”‚ └─▢ Claim multiple epochs in single tx β”‚
β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why Merkle-Based Distribution?​

Traditional staking contracts store on-chain state for every staker. At 100k+ NFTs, this becomes gas-prohibitive.

Merkle approach:

  • Zero on-chain staking state
  • Worker computes all weights off-chain
  • Publishes only a 32-byte merkle root
  • Each claimer provides their own proof

Gas savings:

ApproachOn-chain StorageClaim Gas
Traditional accumulatorO(stakers)~80k
Merkle epochO(1) per epoch~65k

Worker Pipeline Commands​

npm run sync:ownership      # Pull Transfer logs, update ownership state
npm run reconcile:ownership # Verify indexed state matches on-chain
npm run derive:weights # Apply rates to holdings, compute per-account weights
npm run build:epoch # Build merkle tree + epoch artifact
npm run publish:latest # Submit epoch to on-chain contract
npm run run:epoch # Full pipeline (sync β†’ reconcile β†’ derive β†’ build β†’ publish)
npm run run:daemon # Persistent poller for automatic epochs

Mathematical Template Decoding​

NFTs are decoded without a static registry using mathematical type extraction:

const TYPE_ID_MULTIPLIER = 1_000_000n;

if (tokenId < TYPE_ID_MULTIPLIER) {
// Base Token (Rock)
templateId = "Rock";
} else {
// Crafted Token
typeId = tokenId / TYPE_ID_MULTIPLIER;

if (typeId === 1) templateId = "MarbleSlab";
if (typeId === 2) templateId = "TetraGoldium";
// ... more types
}

This eliminates the need for inputs/token_registry.json for crafted tokens.


Rates Engine​

Weights are derived from two inputs:

1. Template Base Weights (inputs/rates.json)​

{
"template_base_weights": {
"Rock": "100",
"MarbleSlab": "250",
"TetraGoldium": "500"
}
}

2. Attribute Multiplier Rules​

{
"attribute_rules": [
{
"trait_type": "rarity",
"value": "Legendary",
"multiplier_bps": 20000
}
]
}

Weight calculation:

baseWeight = template_base_weights[templateId]
finalWeight = baseWeight

for each matching attribute_rule:
finalWeight = (finalWeight * multiplier_bps) / 10000

// Example: Legendary Rock
// 100 * 20000 / 10000 = 200

Reward Modes​

ModeConfigFormula
fixedEPOCH_REWARD_AMOUNTExact amount per epoch
pool_percentPOOL_REWARD_BPSrewardPool Γ— bps / 10000
pool_allβ€”100% of rewardPool

Set via EPOCH_REWARD_MODE=fixed|pool_percent|pool_all.


Storage Backends​

BackendConfigUse Case
fileSTORAGE_BACKEND=fileDevelopment, prototype
postgresSTORAGE_BACKEND=postgresProduction, multi-worker

PostgreSQL tables (auto-created):

  • ownership_sync_cursor(collection_address, last_synced_block, updated_at)
  • ownership_state(collection_address, token_id, owner_address, updated_block, updated_at)

Reorg Safety​

Worker handles chain reorganizations without data corruption:

ControlPurpose
OWNERSHIP_CONFIRMATIONSIgnore newest N blocks until confirmed
OWNERSHIP_RESCAN_BLOCKSReplay overlap window every run
OWNERSHIP_SYNC_CHUNK_SIZEBlock range per RPC call (auto-shrinks on error)

Publish Safety Checks​

Worker enforces fail-closed publishing:

CheckBehavior
REQUIRE_RECONCILE_CLEAN=trueBlock publish if reconciliation has mismatches
RECONCILE_MAX_AGE_MINUTESBlock publish if reconciliation is stale
epoch interval elapsedOn-chain contract enforces minimum time
rewardPool >= totalRewardOn-chain contract reverts if insufficient
epochId == latestEpochId + 1Sequential epoch enforcement

Contract Interface​

function publishEpoch(
uint64 epochId,
bytes32 merkleRoot,
uint256 totalWeight,
uint256 totalReward,
bytes32 snapshotHash
) external; // distributor only

function claim(
uint64 epochId,
uint256 weight,
uint256 amount,
bytes32[] calldata merkleProof
) external;

function batchClaim(
uint64[] calldata epochIds,
uint256[] calldata weights,
uint256[] calldata amounts,
bytes32[][] calldata merkleProofs
) external;

function fundPool(uint256 amount) external;
function withdrawUnallocatedRewards(address to, uint256 amount) external onlyOwner;

Claim Payload Generation​

Worker generates claim payloads for any account:

CLAIM_ACCOUNT=0x... npm run claim:payload

Output: data/claims/<account>_<epochId>.json

{
"epochId": 1,
"account": "0x...",
"weight": "250",
"amount": "1250000000000000000",
"merkleProof": ["0x...", "0x..."]
}

Data Artifacts​

PathContent
data/state/ownership_state.jsonBlock cursor + ownership (file mode)
inputs/holdings_snapshot.jsonMaterialized holdings
inputs/latest_snapshot.jsonPer-account weights
data/epochs/epoch-<id>-<ts>.jsonImmutable epoch artifact
data/latest.jsonPointer to latest artifact
data/logs/*.jsonRun logs
data/state/reconcile_status.jsonReconciliation report

Security Properties​

PropertyMechanism
No reward theftfundPool() only adds. Withdrawal is owner-only for unallocated.
Merkle integrityCryptographic proof required for every claim
Replay protectionclaimed[epochId][account] mapping
Distributor gateOnly authorized addresses can publish epochs
Epoch sequencingepochId must equal latestEpochId + 1
Interval enforcementOn-chain lastPublishedAt + epochInterval check
Reorg resilienceWorker rescan window + reconciliation guard

Design for Collection Agnosticism​

The NFT Staking system is designed to be flexible for different collections and ID schemes. The current Faded Monsuta implementation demonstrates one approach, but the architecture supports variations.

Token ID Handling​

The worker currently supports two approaches for determining token types:

ApproachHow It WorksProsCons
BitwisetypeId = tokenId >> 128Unlimited serials per typeLarge token IDs
MultipliertypeId = tokenId / 1_000_000Readable IDs1M cap per type
Registryinputs/token_registry.json maps IDs to typesFlexibleRequires registry file

The system can be extended to support metadata-driven type detection for fully generic collections.

Making It Collection-Agnostic​

For a truly generic staking system that supports ANY ERC-721 collection:

  1. Multi-collection contract - Pass collection address to stake/unstake
  2. Per-collection rates - Configure rates per collection in the worker
  3. Metadata querying - Worker queries tokenURI for each NFT to determine weight
  4. Attribute extraction - Parse on-chain or IPFS metadata for trait-based weights

Current Faded Monsuta Implementation​

The current implementation uses mathematical type extraction from token IDs:

  • Token ID = typeId << 128 | serial
  • Type extracted via bitwise shift
  • Worker maps types to weights via rates.json

This is specific to Faded Monsuta's ID scheme but demonstrates the pattern.


Deployment Notes​

  1. Deploy NFTStaking proxy via UUPS deployment script
  2. Call setDistributor(workerAddress, true)
  3. Call fundPool(amount) to seed rewards
  4. Configure .env with RPC, collection address, rates
  5. Run npm run run:epoch to publish first epoch
  6. Set up daemon: npm run run:daemon