The introduction

It is well known that the principles of the blockchain world are: Code is Law, an Ethereum smart contract developed based on solidity that contains a series of stored states to support the functionality of dApps; In Dapp provide service process, due to no censorship, decentralized characteristics of block chain, any organization or individual can call at will, at this point, in order to protect the contract according to the predetermined logic operation, need to be in the process of operation of the whole contract, watch, to avoid the contract status deviates from the orbit, appear safe hidden trouble.

The so-called security risks include not only the more eye-catching theft of coins; In order to avoid security risks, we should at least do:

  1. Safety inspection: avoid unexpected situation in the execution process of the contract caused by abnormal external input or return value of other contracts, resulting in capital loss or internal confusion
  2. Prevention: honest users, due to their own carelessness, with the wrong way to call the contract, or call the wrong contract, will cause their own losses; A well-designed contract will try to avoid the loss of the user, stop the execution when the user’s error is discovered, or there is a way to remedy the user’s error
  3. Friction: It is difficult to slide objects on surfaces with high friction coefficient, and a smart contract that is subject to exceptions and revert will not work. In severe cases, friction can cause smart contracts to fail to work properly for users. Hackers can take advantage of the friction in the contract to make an attack that doesn’t benefit anyone: they won’t make your contract work, even if they don’t get the benefit themselves

This article starts with the Solidity language keywords for exception checking and goes on to show how safety checking, freeze prevention and friction should be considered when writing contracts using OneSwap as an example.

Exception checking mechanism for Solidity

Ethereum provides three exception checking mechanisms to check the parameters received by the contract, as well as some intermediate states generated during the contract’s execution. Not only can it avoid the malicious user’s input to destroy the persistent state inside the contract; In addition, when the intermediate state does not meet the predetermined demand during the contract operation, the contract execution can be timely interrupted to reduce the user’s gas consumption.

In Ethereum, contracts written in solidity will roll back all changes made to the contract state by the current call when an exception is encountered; Furthermore, when an exception occurs during a subinvocation, the exception is passed to the upper level to roll back the state change of the upper level contract. Note that there are exceptions here: because Solidity supports the insertion of low-level calling instructions (e.g. : Send, Call, DelegatecAll, Staticcall), these low-level call instructions return values to represent the result of the execution. When an error occurs, the first return value of the instruction returns false and does not throw an exception to the upper level. The upper level call detects the return value of the assembly instruction. To learn the execution status of the instruction and decide whether to roll back the execution status of the upper contract.

  • Note: Use low-level instructionscall.delegatecall.staticcallThe first return value of the directive is also true when calling a nonexistent account (a situation caused by the underlying EVM implementation); Therefore, account existence checks must be made before low-level calls.

Here are three solidity keywords for throwing exceptions: Assert, require, and Revert, and where they apply.

The require and assert keywords check for incoming expressions and throw an exception if the expression is false; The format is require(expression, “some reason with error”), assert(expression, “some reason with error”). When an exception occurs, all state changes of the contract are rolled back because the remaining instructions of the contract cannot be safely executed when the expected results are not obtained. At the same time, in order to maintain the atomicity of the transaction, the safest operation is to roll back all state changes so that the entire transaction has no impact on the on-chain data.

The require expression is used to check the input parameters of the call contract or the return value of the contract after execution. If the result of the expression is false, require throws an exception that rolls back any state changes caused by the current transaction. In the underlying implementation of the Solidity language, the require expression is implemented with the 0xfD directive (REVERT), which does not consume the user’s remaining unused gas.

Assert is used to check the execution status of a contract by detecting errors within the contract. In normal cases, the execution path of a contract does not trigger an ASSERT exception. However, some scenarios carefully constructed by malicious users may not meet the assert check conditions and trigger an exception. As a penalty, Assert consumes all gas left on the current exchange.

Since different instructions have different processing methods for the remaining gas, the contract developer can use require at the entrance of relevant external functions to check the input parameters, return values, and some possible errors of the contract method, so as to timely interrupt the execution and reduce the gas consumption of users when the situation does not meet the expectations. Contract developers can use Assert to provide punitive protection against faulty logic that should not occur in a contract.

Sample code is as follows

contract DemoContract{ uint const TICKET = 10; address pool; function addPositive(uint a, uint b) public payable returns(uint){ require(a > 0 && b > 0, "params are not Positive integer"); // error reason is optional require(this.balance >= TICKET ); assert(pool.send(TICKET), "transfer eth failed"); }}Copy the code

Revert is also a mechanism for triggering an exception in the case of an error. The underlying directive implementation is the same as require; It’s mainly used to detect scenarios where there are too many expressions to be implemented in a single line of code.

Sample code is as follows

contract DemoRevert{
	uint state;	
	function operation(uint num) public{
		if (num > 0 && num < 10){

			// some operation
            ......
			
			if (num > 3){
				revert("Invalid Calculate")
			}		
		}
	}

}
Copy the code

Security check

The need for safety checks is easy to understand. There are a lot of hackers looking at contracts on the chain, and you have to assume that every function of the contract that can be called externally is going to be called in every possible way by the hacker, trying to see if there’s any vulnerabilities that the hacker can exploit. Therefore, the validity check of input parameters and the quantity check of user transaction amount are essential and very common operations.

For example, after a limit order, we need to check the price and the amount of the order:

            require((amount >> 42) == 0, "OneSwap: INVALID_AMOUNT");
            uint32 m = price32 & DecFloat32.MANTISSA_MASK;
            require(DecFloat32.MIN_MANTISSA <= m && m <= DecFloat32.MAX_MANTISSA, "OneSwap: INVALID_PRICE");
Copy the code

After calculating the amount of coins that the user should pay to the contract using the price and order amount and saving it as CTX. remainAmount, we need to query the balance to confirm that the user has paid this amount:

    function _checkRemainAmount(Context memory ctx, bool isBuy) private view {
        ctx.reserveChanged = false;
        uint diff;
        if(isBuy) {
            uint balance = _myBalance(ctx.moneyToken);
            require(balance >= ctx.bookedMoney + ctx.reserveMoney, "OneSwap: MONEY_MISMATCH");
            diff = balance - ctx.bookedMoney - ctx.reserveMoney;
            if(ctx.remainAmount < diff) {
                ctx.reserveMoney += (diff - ctx.remainAmount);
                ctx.reserveChanged = true;
            }
        } else {
            uint balance = _myBalance(ctx.stockToken);
            require(balance >= ctx.bookedStock + ctx.reserveStock, "OneSwap: STOCK_MISMATCH");
            diff = balance - ctx.bookedStock - ctx.reserveStock;
            if(ctx.remainAmount < diff) {
                ctx.reserveStock += (diff - ctx.remainAmount);
                ctx.reserveChanged = true;
            }
        }
        require(ctx.remainAmount <= diff, "OneSwap: DEPOSIT_NOT_ENOUGH");
    }
Copy the code

If the user does not type enough coins, an error is reported and revert; If users send too many coins, they count the extra coins as revenue from the contract. Note that the user is sending too many coins, which does not cause a loss of funds in the contract, but can cause inconsistencies in the data, so this situation must be addressed.

For example, in the BuyBack contract, the specific Pair to exchange tokens is specified by the parameters of the contract. What if a malicious user designates a fake Pair and the funds are stolen after entering the Pair? In this case, we need to go to the Factory contract to check whether this is a false Pair:

function _removeLiquidity(address pair) private { (address a, address b) = IOneSwapFactory(factory).getTokensFromPair(pair); require(a ! = address(0) || b ! = address(0), "OneSwapBuyback: INVALID_PAIR"); . }Copy the code

Fool proof design

Similar to the real world tools (e.g., positive and negative battery shape design to prevent users from going the wrong way), contracts developed with Solidity can also include similar deadidity design (e.g., prevent users from accidentally entering ETH into the contract account, or return assets that users have overloaded into, etc.); The OneSwap contract design adds a lot of these anti-freeze designs.

For example, the ownership of ONES is transferable. The transfer process is not A direct transfer from A to B, but in two steps: FIRST, A sends A transaction to announce that HE is ready to transfer the ownership to B; And then B sends another transaction saying, I accept ownership. As shown in the following code:

modifier onlyOwner() { require(msg.sender == _owner, "OneSwapToken: MSG_SENDER_IS_NOT_OWNER"); _; } modifier onlyNewOwner() { require(msg.sender == _newOwner, "OneSwapToken: MSG_SENDER_IS_NOT_NEW_OWNER"); _; } function changeOwner(address ownerToSet) public override onlyOwner { require(ownerToSet ! = address(0), "OneSwapToken: INVALID_OWNER_ADDRESS"); require(ownerToSet ! = _owner, "OneSwapToken: NEW_OWNER_IS_THE_SAME_AS_CURRENT_OWNER"); require(ownerToSet ! = _newOwner, "OneSwapToken: NEW_OWNER_IS_THE_SAME_AS_CURRENT_NEW_OWNER"); _newOwner = ownerToSet; } function updateOwner() public override onlyNewOwner { _owner = _newOwner; emit OwnerChanged(_newOwner); }Copy the code

Why is it designed this way? Because when A transfers ownership to B, address B may be an address where the private key cannot be found, or an unrelated contract address. If there is a two-step process, the transaction rights will eventually be transferred to B only if the private key at address B exists and the transaction can be sent. If there is A problem with address B, A can also issue A transaction to transfer ownership to C.

For example, in the OneSwapRouter contract, the default callback to receive ETH is removed receive() external payable {}; The function of this method is that users can directly transfer ETH funds to the contract account. After deleting this method, users cannot directly transfer ETH funds to the contract address.

In OneSwapRouter contract, only necessary external interfaces are added to the ‘payable’ modifier to allow ETH to be received, so as to prevent the user from accidentally transferring ETH to the contract account when calling the contract method, for example: RemoveLiquidity The flow interface is removed and the payable modifier is not added.

Looking at this, you might be wondering if the example of the checkRemainAmount function above, where users send too many coins and count the extra coins as the payoff of the contract, isn’t that unfriendly to the stupid users? No, because ordinary users call Pair through the Router, and the Router will help users figure out how many coins to send (for example, in the following listed transaction, the amount of money that the user actually needs to transfer to the capital pool is calculated first, and then the calculated amount is transferred to the capital pool; At the same time, it can also be seen that when placing an order, the amount transferred to the user also includes anti-stay detection, so as to avoid the user transferring to ETH by accident.)

function limitOrder(bool isBuy, address pair, uint prevKey, uint price, uint32 id, uint stockAmount, uint deadline) external payable override ensure(deadline) { (address stock, address money) = _getTokensFromPair(pair); { (uint _stockAmount, uint _moneyAmount) = IOneSwapPair(pair).calcStockAndMoney(uint64(stockAmount), uint32(price)); if (isBuy) { if (money ! = address(0)) { require(msg.value == 0, 'OneSwapRouter: NOT_ENTER_ETH_VALUE'); } _safeTransferFrom(money, msg.sender, pair, _moneyAmount); }... } IOneSwapPair(pair).addLimitOrder(isBuy, msg.sender, uint64(stockAmount), uint32(price), id, uint72(prevKey)); }Copy the code

Due to the characteristics of AMM algorithm and the innate characteristics of blockchain, when users pledge tokens to the capital pool to obtain liquidity each time, they cannot accurately obtain the ratio of the two assets in the capital pool at the moment when the transaction is on the chain. As a result, when users call OneSwapRouter contract to pledge tokens, In most cases, one of the transferred tokens has a surplus (due to uniswap based AMM algorithm, the two tokens pledged need to be proportional). In this case, in order to ensure that the user’s assets are not lost, the OneSwapRouter contract, after each pledge of the token, The user’s excess assets will be automatically transferred to the user’s address.

function _safeTransferFrom(address token, address from, address to, uint value) internal { if (token == address(0)) { _safeTransferETH(to, value); uint inputValue = msg.value; if (inputValue > value) { _safeTransferETH(msg.sender, inputValue - value); } return; }... }Copy the code

Avoid friction

For example: the amount of ERC20 token transfer is 0. Although the amount of ERC20 token transfer is not reasonable, it does not affect the contract or the user. Just return without any action. Too many errors may cause the calling contract to terminate for no reason. After all, it is common sense that when the transfer asset is 0, no state changes will be caused. Cause the contract that calls oneself terminates unreasonably here, is so called “produce friction”.

During the execution of the contract, every time an external contract is invoked, it may Fail to be invoked, leading to a rollback. Even trivial operations such as transferring ETH or ERC20 may Fail. When transferring ETH to a contract, the code logic of the contract may be triggered, and the code fails during execution. One of the most common reasons ERC20 transfers fail is that the Token recipient is blacklisted.

Taker takes an order and transfers a certain amount of ETH or ERC20 Token to the owner of the order. If the transaction fails to revert, this means that the order cannot be closed and is stuck there, preventing subsequent orders from being completed. In OneSwap, the function used to transfer money looks like this:

// safely transfer ERC20 tokens, or ETH (when token==0) function _safeTransfer(address token, address to, uint value, address ones) internal { if(value==0) {return; } if(token==address(0)) { // limit gas to 9000 to prevent gastoken attacks // solhint-disable-next-line avoid-low-level-calls to.call{value: value, gas: 9000}(new bytes(0)); //we ignore its return value purposely return; } // solhint-disable-next-line avoid-low-level-calls (bool success, bytes memory data) = token.call(abi.encodeWithSelector(_SELECTOR, to, value)); success = success && (data.length == 0 || abi.decode(data, (bool))); if(! success) { // for failsafe address onesOwner = IOneSwapToken(ones).owner(); // solhint-disable-next-line avoid-low-level-calls (success, data) = token.call(abi.encodeWithSelector(_SELECTOR, onesOwner, value)); require(success && (data.length == 0 || abi.decode(data, (bool))), "OneSwap: TRANSFER_FAILED"); }}Copy the code

When transferring ETH, ignore the return value of call. If the transfer fails, then the recipient must be a malicious contract account and deliberately construct some malicious scenarios to block the normal operation of the order book. At this time, considering the consideration of other users in the order book, the malicious hacker can only bear the loss. It is impossible to fail to transfer ETH to external accounts and normal contracts. When ERC20 tokens are transferred, if the transfer fails, the assets that were transferred to the recipient are transferred to the owner of ONES tokens. It’s kind of a centralized solution: If the recipient can’t receive the tokens because they’re blacklisted, let the OneSwap project owner hold the tokens on your behalf for a long time and return them to you when you’re off the blacklist. In any case, the order book should not fail to work due to friction caused by the transfer of money.

OneSwap’s BuyBack contract, for example, uses a similar concept to reduce friction during contract execution (as shown below: take the fee income from the transaction pair and use it to BuyBack ONES; When it is found that the relevant transaction median fee income is 0, the BuyBack contract simply stops execution at this time, and no exception will be thrown.

function removeLiquidity(address[] calldata pairs) external override { for (uint256 i = 0; i < pairs.length; i++) { _removeLiquidity(pairs[i]); } } function _removeLiquidity(address pair) private { .... uint256 amt = IERC20(pair).balanceOf(address(this)); if (amt == 0) { return; }... }Copy the code

conclusion

The article focuses on three solidity throwing mechanisms for error handling in decentralized scenarios; For issues that cannot be dealt with in a decentralized manner, a semi-centralized mechanism can be introduced to safeguard user assets (such as the issue of order books being blocked by transfers as described above); At the same time, it also describes many examples to reduce the call friction, as much as possible to reduce the user confusion in the process of the contract is called; It also introduces some common anti-freeze designs to avoid possible asset losses caused by misoperations.

Error handling: Assert, Require, Revert and Exceptions

OneSwap Series 11-Security Verification, Fool- Proof Design, and Friction Prevention for ETH Contracts

Link: oneswap.medium.com/oneswap-ser…

OneSwap Chinese community