NEAR
NEAR protocols issues

Typical and unique issues for the NEAR protocols

2021-08-02
Blockchains abound in today’s crypto world, and while some of them are EVM forks or L2 solutions, others propose new protocols with promising ideas and urgent problems solutions.
Although these innovations bring flexibility, efficiency, and new levels of abstractions, they don’t always protect end users and developers from missteps that engender projects’ vulnerabilities and security leaks. We’ve prepared this article to tell future NEAR protocol SCs developers about the issues typical as well as unique for this platform.
Storage Management on NEAR
In contrast to the Ethereum fees model, where a transaction initiator pays for adding information to storage, NEAR suggests storage or state staking. According to this schema, the contract should lock the number of tokens equivalent to stored data. The developers claim this mechanism gives the opportunity to shift the charge for adding data to the storage from users to the contract owner and “align incentives” for storing large amounts of data. [1]
Problems arise when a contract creator wants to delegate storage payments to users, especially if the contract holds the native currency, inaccuracy in estimation of storage allocation price may cause funds loss. Because the slightest miscalculation in storage costs leads to the contract having to pay “from its own pocket” engaging users’ assets.

The adverse results possibility is razed by a precise evaluation of each storage-adding function. It must be strictly controlled that all methods have been examined on data amount contributed to storage. Once the amounts are investigated, it’s necessary to record them in the contract and check during each call if a user deposited the needed amount corresponding to the method.

The official documentation of the NEAR protocol provides the information on calculation.
Integer Overflow
  • Integer overflow/underflow occurs when an arithmetic operation tries to create a numeric value out of type range without a proper check. [2] E.x. we have a uint8 type variable equal to 157 and we want to add 100 to it, the expected result is 257, but the upper bound of the type is 255 which leads to the sum being the first 8 bits of the actual result, so an add operation will return 1 instead of 257. In 8 bit math it can be presented as 1001 1101 + 110 0100 = 1 0000 0001.
  • The example above represents a case of overflow, but the weakness is relevant for situations reducing the value below the lower type limit described as underflow. So the subtraction of 1 from 0 of uint8 type results in 255. It also should be considered that overflow and underflow are possible with multiplication and division operations besides addition and subtraction.
  • To remediate this problem arithmetic operations are usually wrapped in a safe math library that performs all necessary checks and ensures operations are safe or special asserts are added before precarious arithmetic computations. Sometimes compilers have built-in overflow/underflow checkers and would throw an error automatically while execution. So the latest Solidity versions help to not worry about such inconveniences and automatically verify calculations are correct. Unfortunately, the Rust compiler hasn’t such functionality turned on by default, and developers are forced to take care of the issue by themselves.
  • The code snippet below represents the account’s fungible token balance adjustment functions from the official near-contract-standards library. [8] It demonstrates that while changing balances and total supply add and subtraction arithmetic operations are realized with checked_sub() and checked_add() safe math module methods. It must be noted, that overflow/underflow inside the safe math methods won’t lead to panic, they return None instead of a transaction revert. Therefore, the returned value has to be additionally checked and handled. In this example, values are verified with the unwrap_or_else() method, which panic if None was returned, and Some() method.

pub fn internal_withdraw(&mut self, account_id: &AccountId, amount: Balance) {
        let balance = self.internal_unwrap_balance_of(account_id);
        if let Some(new_balance) = balance.checked_sub(amount) {
            self.accounts.insert(account_id, &new_balance);
            self.total_supply = self
                .total_supply
                .checked_sub(amount)
                .unwrap_or_else(|| env::panic_str(ERR_TOTAL_SUPPLY_OVERFLOW));
        } else {
            env::panic_str("The account doesn't have enough balance");
        }
    }

   pub fn internal_deposit(&mut self, account_id: &AccountId, amount: Balance) {
        let balance = self.internal_unwrap_balance_of(account_id);
        if let Some(new_balance) = balance.checked_add(amount) {
            self.accounts.insert(account_id, &new_balance);
            self.total_supply = self
                .total_supply
                .checked_add(amount)
                .unwrap_or_else(|| env::panic_str(ERR_TOTAL_SUPPLY_OVERFLOW));
        } else {
            env::panic_str("Balance overflow");
        }
    }
Wrong Gas Limit Passed to the Function
Like in other programmable blockchains NEAR charges fees for transactions to encourage validators and miner for processing and storing data. [3] However, asynchronous, sharded runtime is the source of unique problems for the observed protocol. The need to set a gas limit is specific to cross-contract calls, and the exceedance of said limit will revert the transaction. In NEAR you can not get available gas and pass it to a call being sure that you still own it in full amount, since it may be spent on previous asynchronous external calls that were not being accounted yet. [4]

Ideally, all external calls gas expenses should be calculated in advance and passed as exact values. This prevents unexpected gas usage and is essential for validating allocated gas to the call.
assert_one_yocto
Near team rethought the concept of the wallet stepping aside from a user presentation as a public part of his signature usually called address and introduced an account-based paradigm in which transactions are initiated by human-readable account IDs with naming patterns similar to website domains. [6]

An account is controlled by two types of keys: full access keys and function call keys drastically distinguished in interaction scenarios. The full access key gives the privilege to perform any of eight action types under the account without limitations, whereas the function call key can be described as an allowed call to a specific contract, method, or methods with an allocated gas amount. When the allocated gas is spent the key is expired and a new one should be generated.

The uncommon approach to account control seems like the first attempt to differentiate permissions not on a smart contract but on the blockchain layer and share them among the users at the same time. Nevertheless, the hierarchy structure is rather plain yet, since it just distinguishes a superadmin role and a role allowed to call certain contract methods not providing group permission management. Though subaccounts open certain group control possibilities, their application requires further investigation and the community is eager to see the appearance of a project with its great usage example that has a chance to become an industry standard.

From the developers’ side, it’s fair to assume funds-sensitive methods should be called only by account superusers and be reverted if signed with a temporary valid key. A simple security policy is easily pursued considering the function call keys are prohibited to call payable methods. assert_one_yocto() is ready assert is NEAR sdk ensuring exactly one yocto was attached to a call. The minimum possible transfer amount is a reachless barrier for dishonest account co-owners.

Continuing to utilize the NEAR library as the source of examples, it can be seen that ft_transfer() contains assert_one_yocto() in its implementation. [8] As token transfers is considered as a privileged operation and function call keys partially allowed to the account should be restrained from assets movements, the developers added the assert confining access to full access key proprietors.

 fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option<String>) {
        assert_one_yocto();
        let sender_id = env::predecessor_account_id();
        let amount: Balance = amount.into();
        self.internal_transfer(&sender_id, &receiver_id, amount, memo);
    }
Check returned promises/external calls results
Cross contract calls are an integral part of many web3 project architectures and special attention has to be paid to how they work and what threats they may contain for a project or a contract. First of all, it must be kept in mind that the protocol is sharded and external calls inside a contract are asynchronous. Results and completion status of a call are obtained with callbacks. [7]

An egregious mistake would be to not check the result of a returned promise. Promise control is obligatory in order to avoid unexpected transaction revert or return. Due to asynchrony, subcalls failure doesn’t interrupt calls upper in the stack until it is done programmatically in the contract based on returned values handling. In other words, it’s a blatant delusion to suppose the whole transaction will be reverted automatically and all further state changes won’t take effect if an external call inside it fails.

Also, the revert nature might be not so obvious and lay outside of the called contract’s method code, and sometimes is hardly predictable such as gas exceedance in different logic execution branches. In this case, the transaction behavior is similar to direct panic and the only insurance from an accidental call ends is a compulsory check of all returned promises and their appropriate processing.
Besides the regular transfer function, there is one with receiver notification in fungible token implementation. [8] When the tokens are transferred with this method, it calls ft_on_transfer() method on the receiver side, passing the sender address and the amount informing the receiver about the proceeding income. The option is useful for contracts with special deposit logic like stakings or farms, it also gives a user an opportunity to skip the approval stage and straightaway transfer funds shifting responsibilities to smart contract developers. In this sample, after tokens are transferred with internal_transfer(), the receiver's contract ft_on_transfer() method is externally called. The transaction result is awaited by then() method in callback where it is forwarded to a special resolve function.

fn ft_transfer_call(
        &mut self,
        receiver_id: AccountId,
        amount: U128,
        memo: Option<String>,
        msg: String,
    ) -> PromiseOrValue<U128> {
        assert_one_yocto();
        require!(env::prepaid_gas() > GAS_FOR_FT_TRANSFER_CALL, "More gas is required");
        let sender_id = env::predecessor_account_id();
        let amount: Balance = amount.into();
        self.internal_transfer(&sender_id, &receiver_id, amount, memo);
        let receiver_gas = env::prepaid_gas()
            .0
            .checked_sub(GAS_FOR_FT_TRANSFER_CALL.0)
            .unwrap_or_else(|| env::panic_str("Prepaid gas overflow"));
        // Initiating receiver's call and the callback
        ext_ft_receiver::ext(receiver_id.clone())
            .with_static_gas(receiver_gas.into())
            .ft_on_transfer(sender_id.clone(), amount.into(), msg)
            .then(
                ext_ft_resolver::ext(env::current_account_id())
                    .with_static_gas(GAS_FOR_RESOLVE_TRANSFER)
                    .ft_resolve_transfer(sender_id, receiver_id, amount.into()),
            )
            .into()
    }


impl FungibleTokenResolver for FungibleToken {
    fn ft_resolve_transfer(
        &mut self,
        sender_id: AccountId,
        receiver_id: AccountId,
        amount: U128,
    ) -> U128 {
        self.internal_ft_resolve_transfer(&sender_id, receiver_id, amount).0.into()
    }
}
For those, who are just taking their first steps in Rust or NEAR, the resolver function may look bulky, but it’s intoduced primaraly for concept acquaintance. The basic idea, that it must check promise result and have act plan for each of return cases.

impl FungibleToken {
    /// Internal method that returns the amount of burned tokens in a corner case when the sender
    /// has deleted (unregistered) their account while the `ft_transfer_call` was still in flight.
    /// Returns (Used token amount, Burned token amount)
    pub fn internal_ft_resolve_transfer(
        &mut self,
        sender_id: &AccountId,
        receiver_id: AccountId,
        amount: U128,
    ) -> (u128, u128) {
        let amount: Balance = amount.into();

        // Get the unused amount from the `ft_on_transfer` call result.
        let unused_amount = match env::promise_result(0) {
            PromiseResult::NotReady => env::abort(),
            PromiseResult::Successful(value) => {
                if let Ok(unused_amount) = near_sdk::serde_json::from_slice::<U128>(&value) {
                    std::cmp::min(amount, unused_amount.0)
                } else {
                    amount
                }
            }
            PromiseResult::Failed => amount,
        };

        if unused_amount > 0 {
            let receiver_balance = self.accounts.get(&receiver_id).unwrap_or(0);
            if receiver_balance > 0 {
                let refund_amount = std::cmp::min(receiver_balance, unused_amount);
                if let Some(new_receiver_balance) = receiver_balance.checked_sub(refund_amount) {
                    self.accounts.insert(&receiver_id, &new_receiver_balance);
                } else {
                    env::panic_str("The receiver account doesn't have enough balance");
                }

                if let Some(sender_balance) = self.accounts.get(sender_id) {
                    if let Some(new_sender_balance) = sender_balance.checked_add(refund_amount) {
                        self.accounts.insert(sender_id, &new_sender_balance);
                    } else {
                        env::panic_str("Sender balance overflow");
                    }

                    FtTransfer {
                        old_owner_id: &receiver_id,
                        new_owner_id: sender_id,
                        amount: &U128(refund_amount),
                        memo: Some("refund"),
                    }
                    .emit();
                    let used_amount = amount
                        .checked_sub(refund_amount)
                        .unwrap_or_else(|| env::panic_str(ERR_TOTAL_SUPPLY_OVERFLOW));
                    return (used_amount, 0);
                } else {
                    // Sender's account was deleted, so we need to burn tokens.
                    self.total_supply = self
                        .total_supply
                        .checked_sub(refund_amount)
                        .unwrap_or_else(|| env::panic_str(ERR_TOTAL_SUPPLY_OVERFLOW));
                    log!("The account of the sender was deleted");
                    FtBurn {
                        owner_id: &receiver_id,
                        amount: &U128(refund_amount),
                        memo: Some("refund"),
                    }
                    .emit();
                    return (amount, refund_amount);
                }
            }
        }
        (amount, 0)
    }
}
Possible Issues with Royalties
Regarding the recent burst of interest in NFT, it’s necessary to be aware of possible vulnerabilities that may be hidden inside the royalties standard [5]. It’s also required to not simply rely on its authority and ubiquitousness. Some developers blindly skip crucial requirements for returned payouts when working with royalties although lack of imprudence, in this case, is fraught with the contract’s halts and holdings jeopardy.

First of all, royalties should be inspected on total percent violation, it must not be greater than 100%. Although quite an obvious and seemingly jesting requirement, it is not always followed by developers because of greed, inattentiveness, or maliciousness and is a real threat to the contract’s fault tolerance as well as its assets.

Last but not least, a contract should know how to deal with an immense number of payout addresses. Imagine a situation in which the contract received a map with 3000 royalty receivers. The standard doesn’t specify the limit, therefore the contract’s behavior is up to the contract creator. The issue’s severity can hardly be characterized as high, but if handle logic is absent for the case a user will definitely see a reverted transaction with a vague message about prepared gas exceedance.