DEV Community

Gerus Lab
Gerus Lab

Posted on

NFT Staking V2: How We Built a High-Performance Staking System on TON Blockchain

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:

  1. 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.
  2. Sequential reward calculation — every claim triggered a loop over all active stakes to compute pending rewards.
  3. 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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. User calls transfer on their NFT, setting the staking master as new_owner
  2. NFT contract sends ownership_assigned to master
  3. Master verifies NFT is from a whitelisted collection
  4. Master forwards a stake_nft message to the user's child contract (deploying it if needed)
  5. 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
    );
}
Enter fullscreen mode Exit fullscreen mode

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_index that 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;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. User calls master with {op: batch_init, nfts: [addr1, addr2, ...]}
  2. Master stores a pending batch record for this user
  3. User then transfers each NFT to master (one tx per NFT, fired simultaneously off-chain)
  4. 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 ();
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

On unstake request, if cooldown > 0:

  • NFT is marked pending_withdrawal with unlock_at = now() + cooldown
  • NFT stays in master contract
  • After cooldown, user sends claim_unstake and 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)

Collapse
 
doomhammerhell profile image
Mayckon Giovani

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.