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.
// 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();
}
}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
}
}
}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.