NEAR: Sharding & Cross Contract Calls
This is the first part in a three part series. Feel free to checkout the others:
Primer
NEAR Protocol puts in place an innovative sharding solution called "Nightshade" that aims to tackle the problem of high scalability without dropping security and usability. This note looks at the technical details of NEAR's sharding implementation and how cross-contract calls work within this architecture with a focus on security consequences.
The Magic of Nightshade Sharding
Sharding in distributed systems like NFT Bountys is a method of increasing the network’s speed and capacity by dividing the network, here NFT Bounty, into multiple “shards”. This division would allow for each shard to process transactions independently of the others — theoretically increasing the transaction throughput.
Nightshade Sharding
NEAR builds their sharding method called “Nightshade”. Each shard processes a subset of the network’s transactions in parallel. NEAR sharding creates shards (”chunks”) within each block rather of sharding chains. It thus maintains only one chain and allows for asynchronous (”cross-shard”) transactions.
Here is a simplified illustration of what Nightshade looks like compared to the Beacon Chain:
Figure 1. Nightshade sharding illustrated. Source:
Dynamic Resharding
NEAR’s sharding allows for something called “Dynamic Resharding”. It allows NEAR to adjust the number of shards based on network demand on its own. NEAR validators also only need to track the state of their assigned shard.
Cross-Contract Calls: The Bridge Between Shards
Because of the nature of NEAR’s sharding technology, its cross-contract calls are asynchronous and independent. This has material implications on how one should handle the callbacks.
The Building Blocks
Cross-contract calls in NEAR operate through a promise system. Every cross-contract interaction involves two key parts: making the call and handling the result. Here is what happens under the hood:
When Contract A needs to interact with Contract B, it creates a promise via NEAR's SDK. This promise stands for the future result of the call to Contract B. The process is asynchronous, meaning Contract A does not wait idly for Contract B to respond – it continues with other operations while waiting for the callback.
Here is an example of how this works in code:
#[near_bindgen]
impl MyContract {
pub fn initiate_cross_contract_call(&mut self) -> Promise {
Promise::new("contract-b.near".parse().unwrap())
.function_call(
"process_request".to_string(),
json!({
"data": "example"
}).to_string().into_bytes(),
0, // attached deposit
Gas(5_000_000_000_000) // attached gas
)
.then(
Promise::new(env::current_account_id())
.function_call(
"callback_function".to_string(),
Vec::new(),
0,
Gas(2_000_000_000_000)
)
)
}
#[private]
pub fn callback_function(&mut self, #[callback_result] call_result: Result<String, PromiseError>) {
match call_result {
Ok(result) => {
// Handle successful result
}
Err(_) => {
// Handle error case
}
}
}
}Security Considerations
The asynchronous nature of these cross-contract calls brings in possible vulnerabilities that must be carefully considered.
Race conditions present one of the most significant security challenges. During the period between a cross-contract call and its callback execution (usually 1-2 blocks), your contract stays active and callable. This means a malicious user could potentially exploit this window to manipulate the contract state.
Let us inspect two concrete scenarios that illustrate both proper implementation and possible vulnerabilities.
Scenario 1: Normal Operation Flow
In this diagram, we see a legitimate user "Alice" interacting with the smart contract:
- The external contract processes the swap
- A promise result returns from the external contract
- After 2 blocks, the callback executes to decrease the internal NEAR balance
The final state shows Alice with 0N balance and 200 tokens received. This is the expected, secure behavior where the contract maintains proper state management throughout the asynchronous operation.
Scenario 2: Possible Exploit
Even so, without proper protections, a malicious user could exploit the asynchronous nature of cross-contract calls:
In this diagram, we see how an attacker could potentially exploit the system:
- The external contract processes multiple swap requests
- Promise results return from the external contract
- Callbacks execute after 2 blocks, leading to two possible outcomes:
- Working case: NEAR balance decreases to 0, but the attacker receives 400 tokens
- Error case: A panic takes place due to underflow when trying to decrease the balance after the second callback
This vulnerability exists because the original build-out does not prevent multiple stake() calls during the processing period.
Mitigating the Vulnerability
To protect against such exploits, build these essential security measures:
- Enough gas is assigned in the callback function to make the transfer of funds back
Here is an example of how this works in code:
#[near_bindgen]
impl StakingContract {
pub fn stake(&mut self) -> Promise {
// Add state lock to prevent multiple calls
assert!(!self.processing, "Already processing a stake operation");
self.processing = true;
// Store initial state for potential rollback
self.last_stake_amount = self.stake_balance;
Promise::new(self.staking_target.clone())
.function_call(
"stake_tokens".to_string(),
// ... stake parameters ...
Gas(5_000_000_000_000)
)
.then(Self::ext(env::current_account_id())
.with_static_gas(Gas(2_000_000_000_000))
.stake_callback())
}
#[private]
pub fn stake_callback(&mut self, #[callback_result] call_result: Result<(), PromiseError>) {
// Always reset processing flag in callback
let processing = std::mem::replace(&mut self.processing, false);
assert!(processing, "Callback called without active processing");
match call_result {
Ok(_) => {
// Verify state changes are valid
assert!(
self.stake_balance >= self.last_stake_amount,
"Invalid state change detected"
);
}
Err(_) => {
// Rollback any state changes
self.stake_balance = self.last_stake_amount;
env::log_str("Stake operation failed, state rolled back");
}
}
}
}Conclusion
This write-up has covered the essence of what Sharding entails in the NEAR NFT Bounty and has touched upon some of the intricacies of cross-contract calls and their security consequences.
Remember that this type of vulnerability is not unique to our example of staking operations. Any cross-contract call that modifies contract state could be vulnerable to alike race conditions if not properly protected. It is wise to assume that malicious users will attempt to exploit the time window between call and callback execution.
Testing and grasp of these security measures is material for developers and security reviewers. It raises the protocols' security and protects users' assets and keeps the overall ecosystem safe and trusted. We encourage all developers and security reviewers to stay informed and be proactive in finding and mitigating these types of vulnerabilities.
At NFT Bounty, we are committed to securing and hardening NFT Bounty networks and protocols of all kinds. If you are building solutions and want to harness our cutting-edge security expertise in this area, get in touch!