Top 10 DeFi Security Best Practices

Whether you’re building DeFi protocols or any other smart contract application, there are a number of security considerations that need to be taken into account before launching onto a blockchain mainnet. Many teams focus solely on Solidity pitfalls when reviewing their code, but there is often much more to ensuring a dApp’s security is hardened enough to make it mainnet-ready. Being aware of the most popular kinds of DeFi security vulnerabilities, such as oracle attacks, brute force attacks, and many other threats could potentially save you and your users billions of dollars, along with months of headaches and heartache. Whether you’re forking a working codebase or building everything from scratch, doing your due diligence before launching your code is imperative. 

With this in mind, let’s examine 10 best practices for DeFi security that will help prevent your application from falling victim to an attack, mitigate uncomfortable conversations with your users, and protect and bolster your reputation as an ultra-secure developer. 

1. Be Aware of Reentrancy Attacks

One common type of DeFi security attack is the reentrancy attack—the form of the infamous DAO hack. This is when a contract calls an external contract before updating its own state. 

To quote the Solidity documentation:

“Any interaction from a contract (A) with another contract (B) and any transfer of Ether hands over control to that contract (B). This makes it possible for B to call back into A before this interaction is completed.”

Let’s look at an example:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;

// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
    /// @dev Mapping of ether shares of the contract.
    mapping(address => uint) shares;
    /// Withdraw your share.
    function withdraw() public {
        (bool success,) = msg.sender.call{value: shares[msg.sender]}("");
        if (success)
            shares[msg.sender] = 0;
    }
}

In this function, we call another account with `msg.sender.call`. The thing we have to keep in mind is that this could be another smart contract! 

The external contract being called could be coded to call the withdraw function again before `(bool success,) = msg.sender.call{value: shares[msg.sender]}(“”);` returns. This would allow the user to withdraw all the funds in a contract before the state is updated. 

Contracts can have a couple of special functions, namely `receive` and `fallback`. If you send ETH to another contract, it will automatically be routed to the `receive` function. If that `receive` function then points back to the original contract, it would be possible for you to keep withdrawing before you have a chance to update the balance to 0.

Let’s look at what that contract might look like:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;

// THIS CONTRACT IS EVIL - DO NOT USE
contract Steal {
    receive() external payable {
        IFundContract(addressOfFundContract).withdraw();
    }
}

In this function, after you send the ETH to the `steal` contract, it will call the `receive` function, which points back to the `Fund` contract. At this time, we haven’t run `shares[msg.sender] = 0`, so the contract still thinks the user has shares it can withdraw from. 

Solution: Update the internal state of the contract before transferring ETH/tokens or calling an untrusted external contract

There are a few ways to do this, from using mutex locks to even simply ordering your function calls in a manner where you only reach out to external contracts or functions after state has been updated. A simple fix is to update state before calling any external unknown contract:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;

contract Fund {
    /// @dev Mapping of ether shares of the contract.
    mapping(address => uint) shares;

    /// Withdraw your share.
    function withdraw() public {
        uint share = shares[msg.sender];
        shares[msg.sender] = 0;
        (bool success,)=msg.sender.call{value: share}("");
    }
}

Transfer, call, and send

For a long time, Solidity security experts recommended not using the method above. Instead of using the `call` function, they recommended using `transfer`, like so:

payable(msg.sender).transfer(shares[msg.sender]);

The reason we mention this is because you may see conflicting resources out there suggesting something other than what we are going to suggest. Additionally, you’ll hear about the `send` function. Each one of these can be used to send ETH, but all have slight differences. 

  • `transfer`: Has a maximum of 2300 gas, throws an error on failure
  • `send`: Has a maximum of 2300 gas, returns `false` on failure
  • `call`: Forwards all gas to the next contract, returns `false` on failure

Transfer and Send were considered “better” practice for a long time, because 2300 gas is really only enough to emit an event or other harmless operations; the receiving contract can’t callback or do anything malicious except emit mean events, since they’d run out of gas if they tried.  

However, this is only the current setup, and gas costs can and will change in the future due to the ever-changing infrastructure ecosystem. We’ve already seen EIPs that change the gas costs of different opcodes. This means that there may be a time in the future when you can call a function for less than 2300 gas or events will cost more than 2300 gas, meaning any receive function that were to emit an event would now fail in the future.

This means it’s best practice to update state before calling any contracts outside of a project. Another possible mitigation is to impose a mutex upon critical functions, e.g., the non-reentrant modifier in ReentrancyGuard. Adoption of such a mutex will prevent the exchange contract from being reentered upon. This essentially adds a “lock” so no calling contract can “re-enter” the contract while it’s being executed. 

Another version of reentrancy attack is a cross-function reentrancy. Here’s an example of a cross-function reentrancy attack, using the transfer function for the sake of readability:

mapping (address => uint) private userBalances;

function transfer(address _recipient, uint _amount) {
    require(userBalances[msg.sender] >= _amount);
    userBalances[_recipient] += _amount;
    userBalances[msg.sender] -= _amount;
}

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    msg.sender.transfer(amountToWithdraw);
    userBalances[msg.sender] = 0;
}

It is possible to call a function before another function has been completed. This should be a clear reminder to always update state before you send ETH. Some protocols even add mutex locks on their functions so that those functions can’t be called if another function hasn’t returned yet. 

Besides the common reentrancy loopholes, there are also reentrancy attacks that can be triggered by specific EIP mechanisms, such as ERC777. ERC-777 (EIP-777) is an Ethereum token standard built on top of ERC-20 (EIP-20). It is backward compatible with ERC-20 with an added feature that enables an “operator” to send tokens on behalf of a token owner. Critically, the protocol also allows adding “send/receive hooks” for token owners to automatically take further actions upon send/receive transactions.

As can be seen from the Uniswap imBTC hack, the exploit is actually caused by the Uniswap exchange sending ETH prior to the balance change. In that attack, the implementation of the Uniswap function did not follow the well-adopted Checks-Effect-Interact pattern, invented to protect smart contracts against reentrancy attacks, following which the token transfer is supposed to be made prior to any value transfer.

2. Using DEX or AMM Reserves as a Price Oracle Will Result in an Exploit

This is both one of the most common methods used to attack protocols and one of the easiest DeFi security attack vectors to prevent. If you’re using `getReserves()` as a way to quantify price, this should be a red flag. This centralized price oracle exploit occurs when a user manipulates the spot price of an order book or automated market maker-based decentralized exchange (DEX), often through the use of a flash loan. The protocol then uses the price reported by the DEX as their price oracle, causing distortions in the smart contract’s execution in the form of triggering false liquidations, issuing excessively large loans, or triggering unfair trades. Due to this vulnerability, even popular DEXs such as Uniswap don’t recommend using their reserves alone as a pricing oracle. 

 

An oracle is any external entity that fetches external data and delivers it onto a blockchain or does some kind of external computation and delivers the result to the smart contract. In the case of a DEX or AMM-based oracle mechanism, the data source the oracle pulls from is the price of the reserves that were adjusted by the last successful trade on the DEX, which can fall way out of sync with the wider market price of the asset, such as if a large trade is made with little liquidity to service it. This will cause the price to either rise very high (large buy order) or fall very low (large sell order) compared to a volume-weighted average price taken from all exchanges.

A flash loan exacerbates the problem because it allows any user to access a large amount of temporary capital without any collateral in order to execute a large trade. Users often blame the issue on the flash loan, calling them “flash loan attacks.” However, the root problem is that DEXs on their own are insecure price oracles because the spot price can be easily manipulated, causing the protocol relying on the oracle to reference an inaccurate price. These are more accurately described as “oracle manipulation attacks” and have accounted for a large number of exploits in the DeFi ecosystem. All developers should remove oracle manipulation attack vectors in their smart contract.

Let’s look at code from a recent attack that caused $30M in damage, subsequently tanking the price of the protocol’s reward token:

The function has been modified slightly for ease of comprehension, but is effectively the same.

function valueOfAsset(address asset, uint amount) public view override returns (uint valueInBNB, uint valueInDAI) {
    if (keccak256(abi.encodePacked(IProtocolPair(asset).symbol())) == keccak256("Protocol-LP")) {
        (uint reserve0, uint reserve1, ) = IPancakePair(asset).getReserves();
            valueInWETH = amount.mul(reserve0).mul(2).div(IProtocolPair(asset).totalSupply());
            valueInDAI = valueInWETH.mul(priceOfETH()).div(1e18);
    }
}

This protocol has an oracle setup that is sourcing spot prices from a DEX. In the DEX, users can deposit a pair of tokens into a liquidity pool contract (e.g. Token A + Token B), allowing users to swap between these tokens based on an exchange rate, which is calculated by the amount of liquidity on each side of the pool. The assumption was that the protocol was safe, since the majority of its code was a fork of Uniswap’s popular protocol. However, a reward token program was added on top so that when users deposit liquidity into the specific pool, they get not only a receipt token (LP token) which represents a claim to their own liquidity and a percentage of the pool’s fees, but also liquidity mining rewards. A hacker was able to manipulate this reward minting function by taking out a flash loan and depositing that extra capital into the liquidity pool. This allowed them to print reward tokens at the wrong conversion rate. 

In this function, we can see that one of the first things the attacker did was get the exchange rate between the assets in a liquidity pool based on the amount of reserves of both assets in the pool. The following line is called to get the reserves in the liquidity pool:

(uint reserve0, uint reserve1, ) = IProtocolPair(asset).getReserves();

You can imagine a liquidity pool with 5 WETH and 10 DAI would make `reserve0` be `5`, and `reserve1` be 10. WETH stands for “wrapped Ethereum”, which is an ERC20 version of Ethereum with a 1-to-1 conversion rate between ETH and WETH. 

Once you have the amount of reserves in the protocol, the easy way to find the price of either asset would be to divide the two reserves to get an exchange rate. For example, if we have 5 WETH and 10 DAI in our liquidity pool, the conversion rate is 1 WETH to 2 DAI, since we just divide 10 by 5. 

While using decentralized exchanges can be great for swapping assets with instant liquidity, they don’t make for good spot price oracles because their prices can be easily manipulated, especially by flash loans, and only represent a small portion of the total trading volume for any given asset. When used to mint a reward token, the smart contract execution can easily become inaccurate (slightly modified for comprehension):

// ProtocolMinterV2.sol 0x819eea71d3f93bb604816f1797d4828c90219b5d
function mintReward(address asset /* LP token */, uint _withdrawalFee /* 0 */, uint _performanceFee /* 0.00015... */, address to /* attacker */, uint) external payable override onlyMinter {
    uint feeSum = _performanceFee.add(_withdrawalFee);
    _transferAsset(asset, feeSum); // transfers LP tokens from VaultFlipToFlip to this
    uint protocolETHAmount = _zapAssetsToProtoclETH(asset, feeSum, true);

    if (protocolETHAmount == 0) return;

    IEIP20(PROTOCOL_ETH).safeTransfer(PROTOCOL_POOL, protocolETHAmount);
    IStakingRewards(PROTOCOL_POOL).notifyRewardAmount(protocolETHAmount);

    (uint valueInETH,) = priceCalculator.valueOfAsset(PROTOCOL_ETH, protocolETHAmount); // returns inflated value
    uint contribution = valueInETH.mul(_performanceFee).div(feeSum);
    uint mintReward = amountRewardToMint(contribution); 
    _mint(mintReward, to); // mints the reward to the liquidity providers and attacks
}

In this example, the main function that pays the users is the `_mint(mintReward, to);` line. We can see that the function is minting based on how much value a user has locked up on the liquidity pool. So if a user suddenly has a lot of an asset in the liquidity pool (thanks to a flash loan attack), then the user could easily mint themselves a significant amount of the reward token, stealing from the users of that token. 

However, this still wouldn’t have given them the level of profit they desired. So when they were given their extra tokens, the amount of tokens they were given was greatly exacerbated by the manipulated oracle. Let’s say the protocol was thinking it would give the user $5 in rewards—instead, it would issue $5000 in rewards. This is exactly what happened with this specific exploit.

With this setup, a user can easily take out a flash loan, deposit that temporary capital into one side of the liquidity pool, mint a large amount in rewards, and then repay the flash loan, profiting at other liquidity providers’ expense. 

A solution often proposed to avoid the issue of flash loan-funded market manipulation is to take the Time-Weighted-Average-Price (TWAP) from a DEX market (e.g. the average price of an asset over one hour of time). While this prevents flash loans from skewing the oracle price, as flash loans only exist in a single transaction/block and a TWAP is the average across multiple blocks, this is not a complete solution because TWAP has its own trade-offs. TWAP oracles become inaccurate during times of volatility, which can cause downstream events such as not being able to liquidate undercollateralized loans in adequate time. Additionally, TWAP oracles do not provide sufficient market coverage as only a single DEX is being tracked, leaving them vulnerable to liquidity/volume shifts across exchanges, skewing the price given by the TWAP oracle. 

Solution: Use a Decentralized Oracle Network

Instead of using a centralized oracle (in this case, a single on-chain exchange) to determine exchange rates, a DeFi security best practice is using a decentralized oracle network to find the true value of an exchange rate that reflects broad market coverage. A DEX is decentralized as an exchange, but it’s a centralized point of reference for pricing information. 

Instead, you’ll want to collect prices across all liquid centralized and decentralized exchanges, weight the prices by volume, and remove outliers/wash trading to get a decentralized and accurate view of the global exchange rates of the underlying assets, ensuring full market coverage. If you have the asset price that represents a volume-weighted global average from all trading environments, then it won’t matter if a flash loan tanks the price of an asset on a single exchange. 

Additionally, because flash loans only exist within a single transaction (synchronous), they have no effect over decentralized price feeds that generate an oracle update with market-wide pricing in a separate transaction (asynchronous updates). The decentralized architecture of Chainlink oracle networks and the wide market coverage they achieve protects DeFi protocols from flash loan-funded market manipulation, which is why more and more DeFi projects are integrating Chainlink Price Feeds to prevent price oracle exploits and ensure accurate pricing during sudden volume shifts. 

Instead of using `getReserves` to calculate the price, you can instead get your conversion rates from Chainlink Data Feeds, which are decentralized networks of oracle nodes that provide volume-weighted average prices (VWAP) on-chain that reflect all relevant CEXs and DEXs.

pragma solidity ^0.6.7;

import "@chainlink/contracts/src/v0.6/interfaces/AggregatorV3Interface.sol";

contract PriceConsumerV3 {

    AggregatorV3Interface internal priceFeed;

    /**
     * Network: Kovan
     * Aggregator: ETH/USD
     * Address: 0x9326BFA02ADD2366b30bacB125260Af641031331
     */

    constructor() public {
        priceFeed = AggregatorV3Interface(0x9326BFA02ADD2366b30bacB125260Af641031331);
    }

    /**
     * Returns the latest price
     */

    function getThePrice() public view returns (int) {
        (
            uint80 roundID, 
            int price,
            uint startedAt,
            uint timeStamp,
            uint80 answeredInRound
        ) = priceFeed.latestRoundData();
        return price;
    }
}

The code above is all that’s needed to implement access to Chainlink price oracles, and you can read the documentation to start implementing them in your application. If you’re brand new to smart contracts or oracles, we have a beginner walkthrough to help you get started and protect your protocol and its users against flash loan and oracle manipulation attacks. 

If you want to learn more and see all this in action, play OpenZeppelin’s DEX Ethernaut level,  which shows how easy it is to manipulate the spot price of a DEX. 

3. Don’t Use Keccak256 or Blockhash as a Source of Randomness

Using `block.difficulty`, `block.timestamp`, `blockhash`, or anything associated with blocks to get a random number into your application opens up your code to exploits. Randomness in smart contracts can be useful for many use cases, such as determining the winner of a prize giveaway without bias or fairly distributing a rare NFT to users. However, blockchains are deterministic systems and do not provide a tamper-proof source of random numbers, so trying to get a random number without looking outside the blockchain will always present issues and potentially result in an exploit. RNG vulnerabilities aren’t as prevalent as oracle manipulation attacks or reentrancy attacks, but they appear with alarming frequency in Solidity educational materials. A lot of educational content teaches blockchain developers to get a random number with code like this: 

uint randomNumber = uint(keccak256(abi.encodePacked(nonce, msg.sender, block.difficulty, block.timestamp))) % totalSize;

The idea here is to use some combination of a nonce, block difficulty, and timestamp to create a “random” number. However, this has a few glaring shortcomings.

  1. You can actually just keep “rerolling” with canceled transactions until you get a random number that you like. This is really easy for anyone to do.
  2. Using hashed objects like block.difficulty (or really, anything else on-chain) as an RNG gives miners massive influence over the number. Similar to the “rerolling” strategy, miners can use their ability to order transactions to exclude certain transactions from blocks if the result is not favorable to them. Miners can also choose to withhold blocks with a blockhash that is not favorable to them if that is the source of on-chain data used for randomness.
  3. Using things like block.timestamp provides zero randomness, since the timestamp is predictable by anyone.

Using an on-chain random number generator in this way gives users and/or miners influence and control over the “random” number. If you’re looking to have a fair system of any kind, using randomness in this manner will heavily favor malicious actors. This problem only becomes worse as the amount of value being secured by the randomness function increases, as the incentives to exploit it increase.

Solution: Use Chainlink VRF as a Verifiable Randomness Oracle 

To protect against exploits, developers need a way to create randomness that is verifiable and tamper-proof from miners and rerolling users. What is required is randomness sourced off-chain from an oracle. However, many oracles that offer the ability to source randomness have no way to actually prove that the number they deliver was indeed generated randomly (manipulated randomness just looks like normal randomness, you can’t tell the difference). Developers need to be able to source randomness off-chain while also having a way to definitively and cryptographically prove that the randomness has not been manipulated.

Chainlink Verifiable Random Function (VRF) achieves exactly this. It uses oracle nodes to generate a random number off-chain along with a cryptographic proof of the number’s integrity. The cryptographic proof is then checked on-chain by the VRF Coordinator to verify the integrity of the VRF as deterministic and tamper-proof. It works like so:

  1. A user requests a random number from a Chainlink node and provides a seed value. This emits an on-chain event log.
  2. The off-chain Chainlink oracle reads this log and creates a random number and cryptographic proof using a Verifiable Random Function (VRF) based on the node’s keyhash, the user’s given seed, and block data that is unknown during the time of the request. It then returns the random number back on-chain in a second transaction, which is validated on-chain through the VRF Coordinator contract using the cryptographic proof.

How does Chainlink VRF solve the issues described above?

You Can’t Reroll Attack

Since this process takes two transactions, with the second transaction being where the random number is created, you can’t see the random number or cancel your transaction.

Miners Don’t Have Influence

Since Chainlink VRF doesn’t use values that miners have control over, like block.difficulty or values that are predictable like block.timestamp, they can’t control the random number.

Users, oracle nodes, or dApp developers cannot manipulate the randomness data provided by Chainlink VRF, making it an extremely secure source of on-chain randomness for use by smart contract applications.

You can start implementing Chainlink VRF into your code by following the documentation or following our beginner guide for working with Chainlink VRF, which includes a video walkthrough.

4. Avoid Common Glitches

This point is sort of a catch-all for Solidity, but to have a secure contract, you need to build it with all of these DeFi security principles in mind. To write really solid Solidity, you must be aware of how it works under the hood. Otherwise, you might be vulnerable to:

Overflows/Underflows

In Solidity, uint256 and int256 are “wrapped”. This means that if you have a number in uint256 that is of the max uint256 size and you then add to it, it will “wrap” to the lowest number it could be. Be sure to check for this. In Solidity versions prior to 0.8, you’ll want to use something like safemath

In Solidity 0.8.x, arithmetic operations are checked by default. This means that x + y will throw an exception on overflow. So be sure you know what version you’re using!

Loops Gas Limit

When writing loops of dynamic size, you need to be very careful with how big they can get. A loop could easily go over the maximum block size and render your contract useless when it reverts.

Avoid Using tx.origin

`tx.origin` should not be used for authorization in smart contracts, as it can lead to phishing-like attacks.

Proxy Storage Collision

For a project with the proxy implementation pattern, the implementation can be updated by changing the address of the implementation contract in the proxy contract.  

Usually, there is a specific variable storing the implementation contract address in the proxy contract. If this variable’s storage location is fixed and it happens that there is another variable that has the same index/offset of the storage location in the implementation contract, then there will be a storage collision.

pragma solidity 0.8.1;

contract Implementation {
    address public myAddress;
    uint public myUint;

    function setAddress(address _address) public {
        myAddress = _address;
    }
}

contract Proxy {
    address public otherContractAddress;

    constructor(address _otherContract) {
        otherContractAddress = _otherContract;
    }

    function setOtherAddress(address _otherContract) public {
        otherContractAddress = _otherContract;
    }

    fallback() external {
        address _impl = otherContractAddress;
        assembly {
          let ptr := mload(0x40)
          calldatacopy(ptr, 0, calldatasize())
          let result := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0)
          let size := returndatasize()
          returndatacopy(ptr, 0, size)

          switch result
          case 0 { revert(ptr, size) }
          default { return(ptr, size) }
        }
    }
}

To trigger a storage collision, you can follow these steps in Remix:

  1. Deploy the implementation contract;
  2. Deploy the proxy contract with the implementation contract’s deployment address as its constructor argument;
  3. Run the implementation contract on the deployment address of proxy contract;
  4. Call myAddress() function. It will return a non-zero address, which is the deployment address stored in the otherContractAddress variable in the proxy contract.

So, what happened in the four steps above?

  1. The implementation contract is deployed and its deployment address is generated;
  2. The proxy contract is deployed with the deployment address of the implementation contract, where the constructor of the proxy contract is invoked, and the otherContractAddress variable is assigned with the deployment address of the implementation contract;
  3. In step 3, the implementation contract interacts with the proxy storage, i.e., the variable that in the deployed implementation contract can read the value of the corresponding hash-collided variable in the deployed proxy contract.
    Implementation contract Proxy contract
    Storage Slot 0 address public myAddress address public otherContractAddress
    Storage Slot 1 uint public myUint
    Storage Slot 2

    myAddress can read the value of otherContractAddress by collision

  4. The return value of myAddress() function is just the value of the myAddress variable in the deployed implementation contract, which collides with the otherContractAddress variable in the deployed proxy contract, and can get the value of the otherContractAddress variable there.

To avoid proxy storage collision, we advise developers to implement unstructured storage proxies by choosing pseudo-random slots for the storage variables.

One common practice is to adopt a solid proxy pattern for the project. The most widely adopted proxy patterns are Universal Upgradeable Proxy Standard (UUPS) and Transparent Proxy Pattern. Both of them provide concrete storage `offset` in order to avoid the same storage slots being used in both the proxy contract and the implementation contract.

Below is an example of how randomized storage is achieved using the Transparent Proxy Pattern.

bytes32 private constant implementationPosition = bytes32(uint256(
  keccak256('eip1967.proxy.implementation')) - 1
));

Token Transfer Calculation Accuracy

Normally, for an ordinary ERC20 token, the amount of token received should be equal to the original amount called the function with; see the function `retrieveTokens()` below, for example.

function retrieveTokens(address sender, uint256 amount) public {
    token.transferFrom(sender, address(this), amount);
    totalTokenTransferred += amount;
}

However, if the token is deflationary, i.e., there is a fee for each transfer, then the amount of token actually received will be less than the amount of token originally requested to transfer.

In the revised function retrieveTokens(address sender, uint256 amount) shown below, the `amount` is recalculated based on the balances before and after the transfer operation. This will accurately calculate the amount of token that has been transferred to `address(this)`, regardless of the token transfer mechanism.

function retrieveTokens(address sender, uint256 amount) public {
    uint256 balanceBefore = deflationaryToken.balanceOf(address(this));
    deflationaryToken.transferFrom(sender, address(this), amount);
    uint256 balanceAfter = deflationaryToken.balanceOf(address(this));
    amount = balanceAfter.sub(balanceBefore);
    totalTokenTransferred += amount;
}

Proper Data Removal

There are many scenarios that require removing a certain object or value that is no longer needed for the contract. In stature languages like Java, there is a garbage collection mechanism that can handle this automatically and safely. However, in Solidity, the developer has to handle the `garbage` manually. Therefore, incorrectly handling the garbage may bring safety issues to the smart contract.

For example, when deleting a single member from an array with delete, i.e. `delete array[member]`, the `array[member]` will still exist but reset to a default value based on the type of `array[member]`. The developer should remember to either skip this member or reorganize the array and reduce its length. For example:

array[member] = array[array.length - 1];
array.pop()

These are just some of the vulnerabilities to look out for, but understanding Solidity in-depth will help you avoid these “gotchas.” You can check out auditor Sigma Prime’s article on common Solidity vulnerabilities.

5. Function Visibility and Restrictions

In the design of the Solidity language, there are four types of function visibility:

  • private: the function is only visible in the current contract;
  • internal: the function is visible in the current contract and in derived contracts;
  • external: the function is only visible to external calls;
  • public: the function is visible to both internal and external calls.

Function visibility refers to one of the above four visibilities for the specific function, which is applied to limit the access of a certain group of users. As for restriction, it refers to a custom code snippet written specifically for the access restriction purpose.

Visibility and restriction can be combined to set a proper access authorization for specific functions. For example, in the function `_mint()` of the ERC20 implementation:

function _mint(address account, uint256 amount) internal virtual {
    require(account != address(0), "ERC20: mint to the zero address");
    _beforeTokenTransfer(address(0), account, amount);
    _totalSupply += amount;
    _balances[account] += amount;
    emit Transfer(address(0), account, amount);
    _afterTokenTransfer(address(0), account, amount);
}

Function `_mint()`’s visibility is set to internal, which correctly protects it from being called from external. In order to set a proper access authorization for mint functionality, the following code snippet can be used:

function mint(address account, uint256 amount) public onlyOwner {
    _mint(account, amount);
    require(MaxTotalSupply >= _totalSupply, "over mint");
}

Function `mint()` only allows the owner of the contract to mint and the `require()` statement prevents the owner from minting too many tokens. 

Proper use of visibility and restriction benefits contract management. That being said, while on one hand lacking such settings could allow malicious attackers to call administrative configuration functions to manipulate the project, on the other hand, an overly restrictive setting may bring centralization concerns to the contract, which might raise suspicion from your community.

6. Always Get an External Audit Before Deploying to Mainnet

Think of an audit of your code as a security-focused peer review. Auditors will go through your entire codebase line by line and use formal verification techniques to check your smart contracts for any vulnerabilities. Deploying code without an audit or changing code and redeploying after an audit is an easy way to open yourself up to potential vulnerabilities. 

There are a number of ways to help yourself and your auditors ensure your audit is as comprehensive as possible:

  1. Document everything so they have an easier time tracking what’s going on
  2. Keep communication channels open with them in case they have questions
  3. Put comments in your code to make it easier for your team and theirs

However, don’t rely on auditors to catch everything. You should have a security mindset first, because, at the end of the day, you’ll still be the team on the hook if your protocol gets hacked. Security audits don’t necessarily solve everything, but they do provide an additional round of reviews that can be helpful for catching bugs you haven’t discovered. 

Tincho has a great tweet thread on how to best work with auditors. 

Feel free to reach out to our technical experts if you’re looking for an auditor recommendation.

7. Put in Place Testing and Use a Static Analysis Tool

You need to put in place tests on your application. Humans are great, but they will never give you the code coverage that an automated testing suite provides. If you’re looking for a starting point, the Chainlink starter kit repos have some sample testing suites for you to look at. Protocols like Aave and Synthetix have fantastic testing suites too, and it might be a good idea to view their code to get an idea of some best practices for testing (and also for coding more generally). 

Static analysis tools will also help you find bugs earlier. They are designed to automatically run through your contract and look for potential vulnerabilities. One of the current most popular static analysis tools out there is Slither. CertiK is also currently building next-generation static analysis, syntactic analysis, exploit analysis, and formal verification tools based on the insights from its extensive experience in auditing, verifying, and monitoring smart contracts.

8. View Security as a Whole Lifecycle Effort

While you should without any doubt try your best to create a secure and reliable smart contract before production deployment, the fast-evolving reality of blockchain and DeFi protocols and the perpetual invention of new attacks means you cannot afford to stop there. Instead, you should obtain and follow up-to-date monitoring and alert intelligence and, if possible, try to introduce future-proofing in the smart contract itself to access and benefit from the fast-growing quantity of dynamic security intelligence.

A clickable banner to a report detailing the Ultimate Guide to Blockchain Oracle Security
This guide gives a comprehensive breakdown on how to evaluate blockchain oracle security.

It’s also possible to bring in some extra help. CertiK Skynet works as a 24/7 security intelligence engine to provide multi-dimensional and real-time transparent security monitoring for the smart contract on-chain deployment. It includes social sentiment, governance, market volatility, safety assessment, and more to provide general security understandings to the blockchain clients, communities, and token investors. The CertiK Security Leaderboard provides transparent, easy-to-understand security insights and up-to-date project statuses, providing community accountability that incentivizes improvement.

9. Put Together a Disaster Recovery Plan

Depending on your protocol, it’s great to have a bailout plan in case you get hacked. Some popular methodologies are:

  • Getting insurance
  • Installing an emergency “pause” feature
  • Having an upgrade plan

Growing in popularity, insurance protocols are one of the most decentralized ways to recover from a disaster. They add a level of financial security without impacting decentralization. You should have insurance even if you also have other disaster recovery plans. One solution is CertiK’s ShentuShield, an insurance product with added decentralization and transparency.

Putting in place an emergency “pause” feature is a strategy that has its pros and cons. Such a feature stops all interactions with your smart contract in the case that an exploit is found. If you put in place this feature, you need to make sure your users are aware of who is able to operate it. If it’s only one user, you’re not running a decentralized protocol, and a savvy user can go through your code and find out. Be careful with how you implement this, because you may actually end up with a centralized protocol on a decentralized platform. 

An upgrade plan has the same issues. It can be great to move to a bug-free smart contract, but you’ll need to upgrade your contract in a considered way so as not to sacrifice decentralization. Some security companies even go as far as strongly advising against upgradeable smart contract patterns. You can learn more about upgrading your smart contracts in this State of Smart Contract Upgrade address or in Patrick Collins’ YouTube video on the topic. 

Feel free to drop in the Chainlink Discord if you’re looking for some insurance recommendations. 

10. Protect Against Frontrunning

In blockchains, all transactions are visible in the mempool, meaning everyone has the opportunity to see your transaction and potentially make a transaction before yours goes through in order to profit off your transaction. 

For example, let’s say you use a DEX to exchange 5 ETH for DAI at the current market price. Once you send your transaction to the mempool to be processed, a frontrunner could make a transaction to buy a large quantity of ETH just before you, causing the price to rise. They could then sell you the ETH they bought at a higher price and profit at your expense. Frontrunning bots are currently running amok in the blockchain world, profiting at the expense of general users. The term comes from the traditional finance world, where traders try to do the exact same concept but with stocks, commodities, derivatives, and other financial assets and instruments. 

As another example, the function listed below has a high risk of being frontrun. According to the modifier `initializer`, the function can only be called once. If the transaction of calling `initialize()` function is monitored in the mempool by the attacker, the attacker then can `replicate` the transaction with a set of customized values of the token, distributor, and factory, and eventually control the entire contract. As the function `initialize()` can only be called once, the contract owner has no way to defend against or mitigate this attack.

function initialize(IERC20 _token, IDistributor _distributor, IFactory _factory) public initializer {
    Ownable.initialize();
    token = _token;
    distributor = _distributor;
    factory = _factory;
}

This is often also related to what’s known as Miner Extractable Value, or MEV. MEV is when miners or bots reorder transactions so they can profit off the ordering in some way. In the same way that a frontrunner pays more gas to get their transaction ahead of yours, a miner could just reorder transactions to put theirs ahead of yours. MEV accounts for millions of dollars stolen daily from regular users across the blockchain ecosystem. 

Fortunately, a group of world-class smart contract and cryptographic researchers, including Chainlink Labs Chief Scientist Ari Juels, are working on solving this exact problem with a solution called “Fair Sequencing Services.”

Solution in Development: Chainlink Fair Sequencing Services (FSS)

The Chainlink 2.0 whitepaper outlines the key features of Fair Sequencing Services, a secure off-chain service powered by Chainlink Decentralized Oracle Networks (DONs) that will be used to order transactions based on a temporal notion of fairness outlined by the dApp (such as first seen in the mempool). FSS is designed to greatly mitigate the effect of frontrunning and MEV and reduce fees for users across the blockchain ecosystem. You can read more about FSS in this introductory blog post and more expansively in section five of the Chainlink 2.0 whitepaper.

Besides FSS, one of the best ways to mitigate the problem of frontrunning is to reduce the importance of ordering your transactions wherever possible, thereby disincentivizing transaction reordering and MEV within your protocol.

Summary and Next Steps

There are many critical DeFi security considerations when it comes to protecting your smart contracts, and we’ve already seen too many exploits and attacks that have cost users tens of millions of dollars. Mastering the tips above will help you avoid these vulnerabilities when building your smart contracts. However, there will never be an extensive list that covers every single unique vulnerability. We’re continuing to see new and sophisticated forms of economic exploits around centralized mechanisms, as well as flash loan-funded market manipulation around vulnerable collateral. It’s critical for the DeFi community to work together to both surface and mitigate these emerging risks across the ecosystem. 

Learning best practices around secure smart contract development by referring to top projects from places such as the CertiK Security Leaderboard is a helpful approach for improving project security. The smart contract world is open and collaborative, with developers looking to help one another as best they can. 

You may find value in reviewing some previous exploits as a means of understanding how attacks were carried out in the past. You could also receive updates about and learn from the latest on-chain security exploits in real time via 24/7 security intelligence services such as CertiK Skynet.  

For those wanting to dive into security and gamify the whole process, be sure to check out the Ethernaut game. It comes packed with DeFi security examples, teaching you the many ins and outs of Solidity, and it’s a great way to get up to speed on everything security in DeFi. Another gamified project for learning about security is Damn Vulnerable DeFi. You can also learn more about flash loan attacks at the Prevent Flash Loan Attacks website

As a community, let’s learn from our mistakes to ensure smart contracts are built securely and adopted as widely as possible. To learn more about how to implement some of the solutions mentioned here, head over to the Chainlink documentation and CertiK documentation.

Need Integration Support?
Talk to an expert
Faucets
Get testnet tokens
Read the Docs
Technical documentation