NFT Staking V2: How We Built a High-Performance Staking System on TON Blockchain
At Gerus-lab, we have shipped a lot of TON-based products. NFT Staking V2 is one of those projects where we genuinely had to rethink our architecture from scratch — because V1 was holding us back.
This is a deep-dive into what changed, why it changed, and what you can steal for your own staking contracts.
Why V1 Failed Under Load
V1 was straightforward: users deposit NFT → contract records ownership → user earns rewards over time → user withdraws NFT + rewards.
Simple. Works fine with 50 users. Falls apart with 5000.
The core problems:
- State bloat — we stored all staker data in a single contract dictionary. TON charges gas based on cell reads, and reading a fat dictionary is expensive.
- Sequential reward calculation — every claim triggered a loop over all active stakes to compute pending rewards.
- No batch operations — staking 10 NFTs meant 10 separate transactions.
With V2, we threw out the monolithic approach entirely.
Architecture Overview
V2 uses a shard-based design:
[Master Contract]
|
|--- [Staker Contract A] (user 0xAAA)
|--- [Staker Contract B] (user 0xBBB)
|--- [Staker Contract C] (user 0xCCC)
Each user gets their own lightweight child contract deployed on first stake. The master contract handles:
- NFT collection whitelist
- Global reward rate
- Deploying child contracts
Child contracts handle:
- Individual stake records
- Reward calculation per user
- Withdrawal logic
This is a common pattern on TON (similar to how Jetton wallets work), but applying it to NFT staking has some nuances.
Stake Flow
When a user stakes an NFT:
- User calls
transferon their NFT, setting the staking master asnew_owner - NFT contract sends
ownership_assignedto master - Master verifies NFT is from a whitelisted collection
- Master forwards a
stake_nftmessage to the user's child contract (deploying it if needed) - Child contract records
{nft_address, staked_at, reward_debt}
;; Master contract — handle incoming NFT
() handle_ownership_assigned(slice sender, slice payload) impure {
;; sender = NFT address
slice prev_owner = payload~load_msg_addr();
;; verify collection
throw_unless(error::not_whitelisted, is_whitelisted(sender));
;; get or deploy child contract
cell child_state = build_child_state(prev_owner);
slice child_addr = calc_address(child_state);
;; forward stake message
send_raw_message(
begin_cell()
.store_uint(0x18, 6)
.store_slice(child_addr)
.store_coins(stake_forward_gas())
.store_uint(0, 107)
.store_ref(child_state)
.store_uint(op::stake_nft, 32)
.store_slice(sender)
.end_cell(),
64
);
}
Reward Calculation Without Loops
This is the part most people get wrong.
Naive approach: store last_claimed_at and multiply by rate. Works until you change the rate — then historical stakes use the wrong rate.
V2 uses an accumulated reward index (same concept as Synthetix staking rewards on EVM):
- Master contract maintains a global
reward_indexthat increases every second:reward_index += reward_rate * dt - When a user stakes, we snapshot:
reward_debt = current_reward_index - When a user claims:
pending = (current_reward_index - reward_debt) * nft_multiplier
;; Child contract — calculate pending rewards
int calc_pending(int reward_index_now) inline {
int pending = 0;
(int key, slice val, int found) = stakes_dict.udict_get_min?(64);
while (found) {
int reward_debt = val~load_uint(128);
int multiplier = val~load_uint(32);
pending += (reward_index_now - reward_debt) * multiplier;
(key, val, found) = stakes_dict.udict_get_next?(64, key);
}
return pending;
}
Rate changes? Just update reward_rate on master. All historical calculations remain correct because reward_index accumulated correctly up to the change point.
Batch Staking
V2 supports batching up to 20 NFTs in a single user action.
The challenge: NFTs can only be transferred by their owner, so master cannot pull them. We use a batch_stake_init flow:
- User calls master with
{op: batch_init, nfts: [addr1, addr2, ...]} - Master stores a pending batch record for this user
- User then transfers each NFT to master (one tx per NFT, fired simultaneously off-chain)
- As each NFT arrives, master checks if it's part of a pending batch and processes accordingly
Off-chain, we batch-sign and broadcast all NFT transfers in parallel. From the user's perspective it looks instant.
Gas Economics
TON gas is cheap but not free. Key optimizations:
Bounce handling — every internal message can bounce. We always check the bounced flag and refund the NFT if something fails mid-flow.
if (flags & 1) { ;; bounced message
int op = in_msg_body~load_uint(32);
if (op == 0xFFFFFFFF) {
int orig_op = in_msg_body~load_uint(32);
if (orig_op == op::stake_nft) {
return_nft(nft_address, original_owner);
}
}
return ();
}
Excess gas forwarding — child contracts always forward leftover TON back to the user with op::excesses. Users never overpay.
Storage fees — child contracts are small by design. Stake data is stored as a dictionary {nft_addr -> {staked_at, reward_debt, multiplier}} using 64-bit keys (hashed NFT address). Each entry is approximately 80 bytes.
Unstaking and Cooldowns
Some NFT projects require a cooldown before withdrawal (to prevent flash-stake attacks on snapshot-based airdrops).
V2 implements optional cooldown at the collection level:
whitelist_entry = {
collection_addr: MsgAddress,
multiplier: uint32,
cooldown_seconds: uint32 ;; 0 = no cooldown
}
On unstake request, if cooldown > 0:
- NFT is marked
pending_withdrawalwithunlock_at = now() + cooldown - NFT stays in master contract
- After cooldown, user sends
claim_unstakeand NFT is returned
Rewards stop accruing at the moment of unstake request, not at withdrawal.
What We Learned
Shard everything. Monolithic contracts on TON are an antipattern. Deploy child contracts per user — same as Jetton wallets do.
Accumulated index beats timestamp math. Makes rate changes trivial, gas cost predictable.
Always handle bounces. On TON, messages can fail at any hop. Without bounce handling, users lose assets.
Test with real NFT contracts. NFT transfer flows differ between collections. Some use custom payload formats. Always integration-test against real collection contracts, not mocks.
Results
| Metric | V1 | V2 |
|---|---|---|
| Gas per stake | ~0.15 TON | ~0.05 TON |
| Reward calc complexity | O(all stakers) | O(user's stakes) |
| Max stakers tested | 500 | 12,000+ |
| Stuck transactions | occasional | zero |
If you're building NFT staking on TON and want to avoid the mistakes we made in V1, the shard-per-user pattern is the right call. The initial complexity pays off fast once you hit real scale.
Gerus-lab builds GameFi, DeFi, and infrastructure on TON. Check our other deep-dives on TON FSM patterns, Telegram Mini App architecture, and AI-integrated Telegram bots.
Top comments (1)
Interesting read.
What stood out to me isn’t just “NFT staking,” but the engineering trade-offs behind building a high-performance system on TON. On-chain reward accounting, minimizing storage writes, and designing around message-based execution constraints is where the real complexity lives.
In these systems, performance is rarely about raw TPS — it’s about state efficiency, predictable gas usage, and minimizing synchronization overhead between staking logic and NFT ownership validation.
Curious how you approached:
• Reward calculation under high concurrency
• State layout to reduce storage growth
• Handling edge cases around ownership transfer mid-stake
• Upgradeability without breaking reward invariants
High-throughput staking systems are essentially state machines under adversarial conditions. When designed well, the user experience feels simple — but the architecture is anything but.
Nice work.