Gas budgeting for call graphs
Budget gas across a whole NEAR call tree: prepaid vs used gas, static gas and weights per hop, nested callees, callback reserves and the 1 PGas cap.
Advanced11 min read3-question check
Budget gas for cross-contract calls covers the two knobs on a single call. This lesson zooms out to the whole tree of receipts a transaction creates. The rule that governs it: there is exactly one budget, the gas the user attached, and every receipt in the tree lives on the slice its parent handed it. A receipt can never get more gas later.
The numbers inside one receipt#
| Quantity | Meaning |
|---|---|
env::prepaid_gas() | Gas attached to this receipt: the whole transaction’s gas for the first receipt, or the static gas plus leftover share its parent gave it |
env::used_gas() | Gas burnt so far plus gas already reserved for promises this receipt has created. The SDK creates promises when the Promise value is returned or dropped, so reservations appear late |
| Leftover | prepaid − used when the method returns; split between the new receipts by their unused-gas weights. Without any weighted promise it is refunded to the signer |
| Transaction cap | 1,000 Tgas (1 PGas) attached per transaction; it was 300 Tgas until recently |
Budget a tree, not a call#
tx alice.near -> app.near::swap prepaid 100 Tgas
|
R1 app.near::swap burns ~5 creates R2 (min 30, weight 1)
| and R5 (15, weight 0)
| leftover after R1: 100 - 5 - 30 - 15 = 50 -> all to R2
|
+-- R2 dex.near::swap prepaid 30 + 50 = 80, burns ~8
| +-- R3 token.near::ft_transfer 10 static
| +-- R4 dex.near::on_transfer 10 static, weight 0
| (everything R2-R4 don't burn is refunded to alice.near)
|
+-- R5 app.near::on_swap exactly 15: must be enough for the rollback path
# R2's 30 Tgas minimum must cover R2's OWN subtree: itself + R3 + R4.
# If R2 is starved, the DEX fails and R5 rolls back. If R5 is starved, nobody rolls back.Read the static amount you give a callee as “the minimum for its entire subtree”, not for its method body. You usually cannot see inside someone else’s contract, so measure it: run the real callee in a sandbox, sum gas_burnt over all receipts of the call, and add a margin. Then let weights distribute the rest, so a user who attaches more gas makes the deep part of the tree more robust instead of just paying for refunds.
use near_sdk::json_types::U128;
use near_sdk::store::LookupMap;
use near_sdk::{
env, ext_contract, log, near, require, AccountId, Gas, PanicOnDefault, Promise, PromiseError,
};
// measured in sandbox tests, then rounded up with margin
const GAS_FOR_SWAP_SUBTREE: Gas = Gas::from_tgas(30); // dex.swap + its call + its callback
const GAS_FOR_ON_SWAP: Gas = Gas::from_tgas(15); // our callback, worst branch
const GAS_FOR_SELF: Gas = Gas::from_tgas(10); // this method incl. creating 2 receipts
#[ext_contract(ext_dex)]
pub trait Dex {
fn swap(&mut self, pool_id: u64, amount_in: U128, min_out: U128) -> U128;
}
#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct App {
dex: AccountId,
deposits: LookupMap<AccountId, u128>,
}
#[near]
impl App {
pub fn swap(&mut self, pool_id: u64, amount_in: U128, min_out: U128) -> Promise {
// 1. refuse BEFORE any state change if the tree cannot be paid for
let needed = GAS_FOR_SELF
.saturating_add(GAS_FOR_SWAP_SUBTREE)
.saturating_add(GAS_FOR_ON_SWAP);
require!(
env::prepaid_gas() >= needed,
format!("attach at least {} Tgas", needed.as_tgas())
);
let user = env::predecessor_account_id();
let balance = self.deposits.get(&user).copied().unwrap_or(0);
require!(balance >= amount_in.0, "insufficient deposit");
self.deposits.insert(user.clone(), balance - amount_in.0);
ext_dex::ext(self.dex.clone())
.with_static_gas(GAS_FOR_SWAP_SUBTREE)
.with_unused_gas_weight(1) // 2. leftovers go to the deep part of the tree
.swap(pool_id, amount_in, min_out)
.then(
Self::ext(env::current_account_id())
.with_static_gas(GAS_FOR_ON_SWAP)
.with_unused_gas_weight(0) // 3. the callback gets exactly its reserve
.on_swap(user, amount_in),
)
}
#[private]
pub fn on_swap(
&mut self,
user: AccountId,
amount_in: U128,
#[callback_result] out: Result<U128, PromiseError>,
) -> Option<U128> {
log!("on_swap ran with {} Tgas", env::prepaid_gas().as_tgas());
match out {
Ok(amount_out) => Some(amount_out),
Err(_) => {
let current = self.deposits.get(&user).copied().unwrap_or(0);
self.deposits.insert(user, current + amount_in.0);
None
}
}
}
}Rules for deeper graphs#
- Reserve for the rollback path, not the happy path. The callback’s worst branch (restore balances, write logs, forward a refund) is what must fit. Measure that branch.
- Over-asking fails safely; under-reserving fails dangerously. If your static amounts add up to more than you have, the method fails with “Exceeded the prepaid gas” and nothing commits. If the callback is starved, the earlier receipts are committed and the rollback never runs.
- Never let a callee choose your callback’s gas. Gas you give a callee is gone for you; it cannot pass any back. Keep the callback’s reservation in your own
.then. - Make depth a design decision. Each hop adds static minimums and at least one block. If a flow needs more hops than one transaction can fund, split it into steps a user or relayer advances, with the progress stored in state (see Rollback-safe designs).
- Re-measure when dependencies upgrade. A callee’s gas use can change with any redeploy. Keep the sandbox measurement as a test so a regression fails CI rather than mainnet.
Check yourself
3 questions · progress saved in this browser