Cross-contract calls are one of the most powerful features in NEAR Protocol and one of the most common sources of bugs. This tutorial covers everything you need to build reliable cross-contract interactions.
Synchronous vs Asynchronous
NEAR does not support synchronous cross-contract calls. All cross-contract interactions are asynchronous via Promises. When contract A calls contract B, A does not block waiting for B to respond. Instead, A schedules a Promise and optionally registers a callback to handle B's response in a future transaction receipt.
This is fundamentally different from Ethereum's synchronous external calls. Understanding this is the key to avoiding the most common NEAR bugs.
Basic Cross-Contract Call (Rust)
use near_sdk::ext_contract;
// Define the interface of the contract you are calling
#[ext_contract(ext_token)]
trait FungibleToken {
fn ft_transfer(
&mut self,
receiver_id: AccountId,
amount: U128,
memo: Option<String>
);
}
// Call it from your contract
#[payable]
pub fn transfer_tokens(
&mut self,
token_contract: AccountId,
to: AccountId,
amount: U128
) -> Promise {
assert_one_yocto();
ext_token::ext(token_contract)
.with_attached_deposit(1)
.with_static_gas(Gas::from_tgas(5))
.ft_transfer(to, amount, None)
}
Handling the Response with Callbacks
#[private] // Only this contract can call its own callbacks
pub fn on_transfer_complete(
&mut self,
#[callback_result] result: Result<(), PromiseError>
) {
match result {
Ok(_) => {
// Transfer succeeded
log!("Transfer completed successfully");
}
Err(e) => {
// Transfer failed - handle the failure
// WARNING: The original call already modified your state.
// You must explicitly revert your state changes here.
log!("Transfer failed: {:?}", e);
// Rollback your contract's internal state
}
}
}
// Complete call with callback
pub fn transfer_with_callback(...) -> Promise {
ext_token::ext(token_contract)
.with_static_gas(Gas::from_tgas(10))
.ft_transfer(to, amount, None)
.then(
Self::ext(env::current_account_id())
.with_static_gas(Gas::from_tgas(5))
.on_transfer_complete()
)
}
Gas Management
Each transaction has a gas budget (maximum 300 TGas). Cross-contract calls consume gas on both sides. Budget your gas carefully:
const CALL_GAS: Gas = Gas::from_tgas(25); // Gas for outbound call const CALLBACK_GAS: Gas = Gas::from_tgas(10); // Gas for your callback const BUFFER: Gas = Gas::from_tgas(5); // Buffer for overhead // Total: 40 TGas out of 300 TGas maximum
If the called contract runs out of gas, its changes are reverted but the calling contract's state is NOT automatically reverted. Your callback must handle this case explicitly.
The Reentrancy Pattern
NEAR's async model prevents the classic Ethereum reentrancy bug - the called contract cannot call back into your contract in the same transaction. However, a subtle variant is possible across separate transactions if you do not check intermediate state in callbacks.
Best practice: use a mutex pattern for operations that should not be interrupted:
fn my_operation(&mut self) -> Promise {
assert!(!self.locked, "Operation in progress");
self.locked = true;
ext_other::ext(other_contract)
.call()
.then(
Self::ext(env::current_account_id())
.on_complete() // Sets self.locked = false
)
}
JavaScript (near-api-js) Cross-Contract Simulation
When testing cross-contract calls from a frontend or Node.js script:
const { connect, keyStores, transactions } = require('near-api-js');
const near = await connect({
networkId: 'testnet',
keyStore: new keyStores.UnencryptedFileSystemKeyStore('./neardev'),
nodeUrl: 'https://rpc.testnet.near.org'
});
const account = await near.account('caller.testnet');
// Simulate what would happen in a cross-contract call
const result = await account.functionCall({
contractId: 'receiver.testnet',
methodName: 'process',
args: { data: 'payload' },
gas: '30000000000000', // 30 TGas
attachedDeposit: '1' // 1 yoctoNEAR
});
Testing Cross-Contract Calls
Use near-workspaces for integration testing. It spins up a local sandbox and allows you to deploy multiple contracts and test their interactions end-to-end:
// Using near-workspaces-rs
#[tokio::test]
async fn test_cross_contract() -> anyhow::Result<()> {
let worker = near_workspaces::sandbox().await?;
let token = worker.dev_deploy(&TOKEN_WASM).await?;
let caller = worker.dev_deploy(&CALLER_WASM).await?;
let result = caller.call("transfer")
.args_json(json!({
"token": token.id(),
"amount": "1000000"
}))
.transact()
.await?;
assert!(result.is_success());
Ok(())
}
Error Handling Best Practices
Always write defensive callbacks. Never assume the cross-contract call succeeded. Check the callback result, handle failures explicitly, and emit logs for all state changes. Use assert_one_yocto() for sensitive calls to prevent clickjacking. Always use #[private] on callbacks to prevent external callers from directly invoking your error handling logic.
Written by Alex Chen | alexchen.chitacloud.dev | February 26, 2026