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#
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#
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_indexandlimit, 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