Near Learn

Unbounded iteration bricks features

Looping over a growing collection in a NEAR change method eventually exceeds the per-call gas limit. Use pagination, bounded batches and per-user state instead.

Advanced8 min read3-question check

Every NEAR function call has a hard gas ceiling — 1 PGas (1,000 Tgas) per call on current mainnet (it was 300 Tgas for years) — and every storage read inside a loop costs gas. A loop over “all users” works in testing with ten users, and fails forever once there are ten thousand. Because the method can never finish, the feature is bricked: no amount of attached gas fixes it.

If the loop guards something important — paying out rewards, closing a round, migrating state — the funds behind it are stuck too.

The bug#

Vulnerable — pays everyone in one call
Rust
#[near]
impl Pool {
    pub fn distribute(&mut self, total: U128) {
        self.assert_owner();
        let n = self.members.len() as u128;
        require!(n > 0, "no members");
        let share = total.0 / n;
        // BUG: cost grows with every new member. Past some size this
        // exceeds the per-call gas limit and can never succeed again.
        for member in self.members.iter() {
            let current = self.rewards.get(member).copied().unwrap_or(0);
            self.rewards.insert(member.clone(), current + share);
        }
    }
}

The fix#

Fixed — O(1) accounting: accrue globally, settle per user on demand
Rust
use near_sdk::json_types::U128;
use near_sdk::store::{LookupMap, Vector};
use near_sdk::{env, near, require, AccountId, PanicOnDefault};

const PRECISION: u128 = 1_000_000_000_000; // fixed-point scale

#[near(serializers = [borsh])]
pub struct Member {
    shares: u128,
    reward_debt: u128, // shares * acc_per_share / PRECISION at last settlement
    pending: u128,
}

#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Pool {
    owner: AccountId,
    total_shares: u128,
    acc_per_share: u128, // rewards per share, scaled by PRECISION
    members: LookupMap<AccountId, Member>,
    member_list: Vector<AccountId>, // only for paginated views
}

#[near]
impl Pool {
    // constant cost no matter how many members there are
    pub fn distribute(&mut self, total: U128) {
        require!(env::predecessor_account_id() == self.owner, "not owner");
        require!(self.total_shares > 0, "no shares");
        let add = total.0.checked_mul(PRECISION).expect("overflow") / self.total_shares;
        self.acc_per_share = self.acc_per_share.checked_add(add).expect("overflow");
    }

    // each user settles their own account (pull, not push)
    pub fn claimable(&self, account_id: AccountId) -> U128 {
        let Some(m) = self.members.get(&account_id) else { return U128(0) };
        let accrued = m.shares * self.acc_per_share / PRECISION;
        U128(m.pending + accrued.saturating_sub(m.reward_debt))
    }

    // views that list things always take a page
    pub fn get_members(&self, from_index: u32, limit: u32) -> Vec<AccountId> {
        let limit = limit.min(100); // hard cap, whatever the caller asks
        self.member_list
            .iter()
            .skip(from_index as usize)
            .take(limit as usize)
            .cloned()
            .collect()
    }
}
  • Search for .iter(), .values(), .keys() and .clear() on collections in change methods — each is a red flag unless the collection is bounded.
  • Every view that returns a list takes from_index and limit, with a hard cap.
  • Cap per-account collections (e.g. max items per user) so one account cannot grow without limit.
  • Test with realistic sizes in sandbox, not with three entries.

Check yourself

3 questions · progress saved in this browser

  1. 1.A close_round method loops over every participant and works fine in testnet with 50 users. On mainnet it reaches 40,000 users. What happens?
  2. 2.Which design keeps reward distribution cheap regardless of the number of stakers?
  3. 3.A view get_all_orders() returns every order. What is the right fix?