NEAR's execution model is asynchronous. When one contract calls a method on another contract, the call is queued as a Promise. The calling contract does not block waiting for the result. This is different from Ethereum's synchronous call model and requires a different mental model for composable contract design.
The Promise API
Cross-contract calls in NEAR use the Promise API. In Rust near-sdk, the standard approach is:
#[near_bindgen]
impl MyContract {
pub fn call_other_contract(&self, other_contract: AccountId) -> Promise {
ext_other_contract::some_method(
"argument".to_string(),
other_contract,
0, // attached NEAR
10_000_000_000_000, // gas
)
}
}
The ext_ prefix is the standard convention for external contract interface definitions. You define the interface with the #[ext_contract] macro:
#[ext_contract(ext_other_contract)]
pub trait OtherContract {
fn some_method(&self, arg: String);
fn another_method(&self) -> u64;
}
Gas allocation is critical. When you create a cross-contract call, you must specify how much gas to allocate for the called contract's execution. Gas that is not used is refunded, but if the called contract runs out of gas, the call fails. A common mistake is allocating too little gas for complex called contracts.
Callbacks
Most cross-contract calls need to process the result. You chain a callback using .then():
pub fn call_with_callback(&self, other: AccountId) -> Promise {
ext_other_contract::get_value(
other.clone(),
0,
5_000_000_000_000,
)
.then(
Self::ext(env::current_account_id())
.with_static_gas(Gas(5_000_000_000_000))
.on_get_value_complete()
)
}
#[private]
pub fn on_get_value_complete(
&mut self,
#[callback_result] call_result: Result<u64, PromiseError>
) {
match call_result {
Ok(value) => {
// process the returned value
self.stored_value = value;
}
Err(e) => {
log!("Cross-contract call failed: {:?}", e);
}
}
}
The #[private] attribute ensures that only the contract itself can call the callback. This is important: callbacks must be restricted to prevent external callers from injecting fake results.
Error Handling Patterns
Cross-contract calls can fail for several reasons: the called contract panics, runs out of gas, the account does not exist, or the method does not exist. The PromiseError type captures these failure modes.
Always handle errors in callbacks. A common mistake is assuming the cross-contract call succeeded and not checking the Result. If the call failed but your contract stored side effects before the call (modified state, transferred NEAR), those effects are not automatically rolled back. NEAR does not have atomic cross-contract transactions.
The lack of atomic cross-contract transactions is the most important design constraint to understand. If Contract A transfers NEAR to Contract B and then calls Contract B which panics, the NEAR transfer is not reversed. You must design your contracts to handle this explicitly, using techniques like escrow patterns where state changes are only finalized after confirming the cross-contract call succeeded.
Gas Planning
Gas allocation for cross-contract calls requires careful planning. The total gas available per transaction is 300 TGas (300 * 10^12 gas units). If you have multiple cross-contract calls in a chain, you need to budget gas for each hop plus the callbacks.
A simple two-contract call chain might allocate:
- Initial call processing: 20 TGas
- Cross-contract call gas: 100 TGas
- Callback processing: 10 TGas
- Buffer: 170 TGas remaining
For complex orchestration contracts that make multiple calls, track gas usage carefully. The near-sdk Gas type and the gas constants in near_sdk::Gas help with this.
Testing Cross-Contract Calls
The near-workspaces-rs library provides the best testing infrastructure for cross-contract scenarios. It runs a sandbox NEAR environment locally and allows you to deploy multiple contracts and test their interactions:
#[tokio::test]
async fn test_cross_contract() -> anyhow::Result<()> {
let worker = workspaces::sandbox().await?;
let contract_a = worker.dev_deploy(&std::fs::read("contract_a.wasm")?).await?;
let contract_b = worker.dev_deploy(&std::fs::read("contract_b.wasm")?).await?;
// Call A which will call B
let result = contract_a
.call("call_b")
.args_json(json!({"b_contract": contract_b.id()}))
.max_gas()
.transact()
.await?;
assert!(result.is_success());
Ok(())
}
Common Pitfalls
Not marking callbacks as #[private]: External callers can invoke your callback with fake data, potentially manipulating your contract's state.
State changes before cross-contract calls: If your contract modifies state before making a cross-contract call and the call fails, the state change persists. Design your state transitions to be safe in both the success and failure case, or defer state changes to the callback.
Insufficient gas: Always add buffer to your gas allocations. If a called contract is upgraded to do more work, your hardcoded gas allocation may become insufficient. Consider reading gas requirements dynamically or using generous allocations.
Forgetting that Promises execute asynchronously: Values returned from cross-contract calls are not available in the calling function. They are only available in the callback. This is a common confusion for developers coming from Ethereum's synchronous model.
Resources
The NEAR documentation on cross-contract calls is at docs.near.org/build/smart-contracts/anatomy/crosscontract. The near-sdk examples repository has working cross-contract examples. The near-workspaces-rs repository is the authoritative testing framework.