Cross-contract calls

Cross-contract calls

The biggest mental shift: calls to other contracts are asynchronous. You schedule the call and a callback receives the result in a later step — no inline return value.

This is the largest difference from the EVM. In Solidity, calling another contract is synchronous — you get the return value on the next line. On NEAR every cross-contract call is asynchronous: you schedule it, your method ends, and a separate callback method receives the result in a later block.

Read another contract’s value
Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface ICounter {
    function getCount() external view returns (uint256);
}

contract Caller {
    uint256 public lastSeen;

    function fetch(address counter) external {
        // synchronous: the value is available immediately
        lastSeen = ICounter(counter).getCount();
    }
}
Rust
use near_sdk::{near, env, ext_contract, AccountId, Gas, Promise, PromiseError};

// the interface of the contract we want to call
#[ext_contract(ext_counter)]
trait Counter {
    fn get_count(&self) -> u64;
}

#[near(contract_state)]
#[derive(Default)]
pub struct Caller {
    last_seen: u64,
}

#[near]
impl Caller {
    pub fn fetch(&self, counter: AccountId) -> Promise {
        // schedule the call, then a callback to handle its result
        ext_counter::ext(counter)
            .with_static_gas(Gas::from_tgas(5))
            .get_count()
            .then(
                Self::ext(env::current_account_id())
                    .with_static_gas(Gas::from_tgas(5))
                    .on_fetched(),
            )
    }

    #[private] // only this contract may call its own callback
    pub fn on_fetched(&mut self, #[callback_result] res: Result<u64, PromiseError>) -> u64 {
        match res {
            Ok(count) => {
                self.last_seen = count;
                count
            }
            Err(_) => self.last_seen, // the call failed — decide what to do
        }
    }
}
Coming from EVM

A Solidity interface + synchronous call ↔ #[ext_contract] + a scheduled Promise + a callback. The value is not available inline — fetch returns a Promise, and on_fetched gets the result later.

The callback is marked #[private] so only the contract itself can invoke it, and it takes #[callback_result] res: Result<T, PromiseError> — you must handle the failure case, because the other contract may panic or run out of gas.

You attach a gas budget to each leg with .with_static_gas(..). Because there is no synchronous return, the reentrancy shape is different too: state you changed before scheduling is already committed when the callback runs.