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:
| Approach | On-chain Storage | Claim Gas |
|---|---|---|
| Traditional accumulator | O(stakers) | ~80k |
| Merkle epoch | O(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β
| Mode | Config | Formula |
|---|---|---|
fixed | EPOCH_REWARD_AMOUNT | Exact amount per epoch |
pool_percent | POOL_REWARD_BPS | rewardPool Γ bps / 10000 |
pool_all | β | 100% of rewardPool |
Set via EPOCH_REWARD_MODE=fixed|pool_percent|pool_all.
Storage Backendsβ
| Backend | Config | Use Case |
|---|---|---|
file | STORAGE_BACKEND=file | Development, prototype |
postgres | STORAGE_BACKEND=postgres | Production, 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:
| Control | Purpose |
|---|---|
OWNERSHIP_CONFIRMATIONS | Ignore newest N blocks until confirmed |
OWNERSHIP_RESCAN_BLOCKS | Replay overlap window every run |
OWNERSHIP_SYNC_CHUNK_SIZE | Block range per RPC call (auto-shrinks on error) |
Publish Safety Checksβ
Worker enforces fail-closed publishing:
| Check | Behavior |
|---|---|
REQUIRE_RECONCILE_CLEAN=true | Block publish if reconciliation has mismatches |
RECONCILE_MAX_AGE_MINUTES | Block publish if reconciliation is stale |
epoch interval elapsed | On-chain contract enforces minimum time |
rewardPool >= totalReward | On-chain contract reverts if insufficient |
epochId == latestEpochId + 1 | Sequential 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β
| Path | Content |
|---|---|
data/state/ownership_state.json | Block cursor + ownership (file mode) |
inputs/holdings_snapshot.json | Materialized holdings |
inputs/latest_snapshot.json | Per-account weights |
data/epochs/epoch-<id>-<ts>.json | Immutable epoch artifact |
data/latest.json | Pointer to latest artifact |
data/logs/*.json | Run logs |
data/state/reconcile_status.json | Reconciliation report |
Security Propertiesβ
| Property | Mechanism |
|---|---|
| No reward theft | fundPool() only adds. Withdrawal is owner-only for unallocated. |
| Merkle integrity | Cryptographic proof required for every claim |
| Replay protection | claimed[epochId][account] mapping |
| Distributor gate | Only authorized addresses can publish epochs |
| Epoch sequencing | epochId must equal latestEpochId + 1 |
| Interval enforcement | On-chain lastPublishedAt + epochInterval check |
| Reorg resilience | Worker 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:
| Approach | How It Works | Pros | Cons |
|---|---|---|---|
| Bitwise | typeId = tokenId >> 128 | Unlimited serials per type | Large token IDs |
| Multiplier | typeId = tokenId / 1_000_000 | Readable IDs | 1M cap per type |
| Registry | inputs/token_registry.json maps IDs to types | Flexible | Requires 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:
- Multi-collection contract - Pass collection address to stake/unstake
- Per-collection rates - Configure rates per collection in the worker
- Metadata querying - Worker queries tokenURI for each NFT to determine weight
- 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β
- Deploy
NFTStakingproxy via UUPS deployment script - Call
setDistributor(workerAddress, true) - Call
fundPool(amount)to seed rewards - Configure
.envwith RPC, collection address, rates - Run
npm run run:epochto publish first epoch - Set up daemon:
npm run run:daemon