Near Learn

Budget gas for cross-contract calls

Budget gas for NEAR cross-contract calls: with_static_gas, with_unused_gas_weight, reserving gas for the callback, and batching actions into one receipt.

Advanced8 min read3-question check

A cross-contract call does not run inside your method. It becomes a new receipt that executes later, possibly on another shard, with its own gas carved out of what your call had left. You control that split with two knobs on every call: static gas, a fixed amount reserved for it, and an unused gas weight, its share of whatever gas is left over when your method finishes.

The default weight is 1. Leftover gas is divided between scheduled calls in proportion to their weights; a call with weight 0 gets exactly its static gas and nothing more.

Static gas, weights and a callback#

Withdraw tokens with an optimistic debit and a rollback callback
Rust
use near_sdk::json_types::U128;
use near_sdk::store::LookupMap;
use near_sdk::{
    env, ext_contract, is_promise_success, near, require, AccountId, Gas, NearToken,
    PanicOnDefault, Promise,
};

// starting points: measure your real numbers in sandbox tests and keep a margin
const GAS_FOR_FT_TRANSFER: Gas = Gas::from_tgas(10);
const GAS_FOR_CALLBACK: Gas = Gas::from_tgas(10);

#[ext_contract(ext_ft)]
trait FungibleToken {
    fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option<String>);
}

#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Vault {
    token: AccountId,
    balances: LookupMap<AccountId, u128>,
}

#[near]
impl Vault {
    pub fn withdraw(&mut self, amount: U128) -> Promise {
        let who = env::predecessor_account_id();
        let balance = self.balances.get(&who).copied().unwrap_or(0);
        require!(balance >= amount.0, "not enough balance");
        // debit now; the callback restores it if the transfer fails
        self.balances.insert(who.clone(), balance - amount.0);

        ext_ft::ext(self.token.clone())
            .with_attached_deposit(NearToken::from_yoctonear(1))
            .with_static_gas(GAS_FOR_FT_TRANSFER) // default weight 1: gets the leftovers
            .ft_transfer(who.clone(), amount, None)
            .then(
                Self::ext(env::current_account_id())
                    .with_static_gas(GAS_FOR_CALLBACK)
                    .with_unused_gas_weight(0) // exactly its static gas
                    .on_withdraw(who, amount),
            )
    }

    #[private]
    pub fn on_withdraw(&mut self, who: AccountId, amount: U128) {
        // small and panic-free: restore the balance if ft_transfer failed
        if !is_promise_success() {
            let balance = self.balances.get(&who).copied().unwrap_or(0);
            self.balances.insert(who, balance + amount.0);
        }
    }
}

Never starve the callback#

The callback runs in its own receipt and can fail on its own. By then withdraw has already committed the debit. If the token transfer fails and the callback runs out of gas, nothing restores the balance: the user’s funds are simply gone. A callback is your only chance to react to a failed call, so it must be guaranteed enough gas.

Keep callbacks small and free of avoidable panics, give them a measured static budget, and refuse to start the whole flow when the caller attached too little gas to finish it:

Fail early, before any state changes, if the flow cannot complete
Rust
// at the top of withdraw(), before touching state
const GAS_FOR_WITHDRAW_ITSELF: Gas = Gas::from_tgas(5);

require!(
    env::prepaid_gas()
        >= GAS_FOR_FT_TRANSFER
            .saturating_add(GAS_FOR_CALLBACK)
            .saturating_add(GAS_FOR_WITHDRAW_ITSELF),
    "attach more gas"
);

Batch actions in one receipt#

Actions chained on one Promise run in a single receipt: all succeed or all are reverted
Rust
// create, fund, deploy and initialise a sub-account in one receipt
Promise::new(sub_account)
    .create_account()
    .transfer(NearToken::from_near(2))
    .deploy_contract(code)
    .function_call(
        "new".to_string(),
        b"{}".to_vec(),
        NearToken::from_near(0),
        Gas::from_tgas(20),
    )

Check yourself

3 questions · progress saved in this browser

  1. 1.With 100 Tgas left, call A has 20 Tgas static gas and weight 1, call B has 10 Tgas static gas and weight 3. How much does each receive?
  2. 2.Why is a callback that runs out of gas dangerous?
  3. 3.What is true of several actions chained on a single Promise::new(account)?