DeFi Smart Contract Development: Building Secure Protocols

January 5, 2024 (1y ago)

DeFi Smart Contract Development: Building Secure Protocols

Decentralized Finance (DeFi) has revolutionized the financial industry by creating permissionless, transparent, and composable financial protocols. Building secure DeFi smart contracts requires deep understanding of both blockchain technology and financial mechanisms.

Understanding DeFi Protocols

Core Components

Security Considerations

Building a DEX (Decentralized Exchange)

1. Constant Product Market Maker

Implement the classic x * y = k formula:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
 
contract UniswapV2Pair {
    uint public constant MINIMUM_LIQUIDITY = 10**3;
    bytes4 private constant SELECTOR = bytes4(keccak256(bytes('transfer(address,uint256)')));
    
    address public factory;
    address public token0;
    address public token1;
    
    uint112 private reserve0;           // uses single storage slot, accessible via getReserves
    uint112 private reserve1;           // uses single storage slot, accessible via getReserves
    uint32  private blockTimestampLast; // uses single storage slot, accessible via getReserves
    
    uint public price0CumulativeLast;
    uint public price1CumulativeLast;
    uint public kLast; // reserve0 * reserve1, as of immediately after the most recent liquidity event
    
    uint private unlocked = 1;
    
    modifier lock() {
        require(unlocked == 1, 'UniswapV2: LOCKED');
        unlocked = 0;
        _;
        unlocked = 1;
    }
    
    function getReserves() public view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) {
        _reserve0 = reserve0;
        _reserve1 = reserve1;
        _blockTimestampLast = blockTimestampLast;
    }
    
    function _safeTransfer(address token, address to, uint value) private {
        (bool success, bytes memory data) = token.call(abi.encodeWithSelector(SELECTOR, to, value));
        require(success && (data.length == 0 || abi.decode(data, (bool))), 'UniswapV2: TRANSFER_FAILED');
    }
    
    event Mint(address indexed sender, uint amount0, uint amount1);
    event Burn(address indexed sender, uint amount0, uint amount1, address indexed to);
    event Swap(
        address indexed sender,
        uint amount0In,
        uint amount1In,
        uint amount0Out,
        uint amount1Out,
        address indexed to
    );
    event Sync(uint112 reserve0, uint112 reserve1);
    
    function mint(address to) external lock returns (uint liquidity) {
        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        uint balance0 = IERC20(token0).balanceOf(address(this));
        uint balance1 = IERC20(token1).balanceOf(address(this));
        uint amount0 = balance0.sub(_reserve0);
        uint amount1 = balance1.sub(_reserve1);
        
        bool feeOn = _mintFee(_reserve0, _reserve1);
        uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
        if (_totalSupply == 0) {
            liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
           _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
        } else {
            liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
        }
        require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED');
        _mint(to, liquidity);
        
        _update(balance0, balance1, _reserve0, _reserve1);
        if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
        emit Mint(msg.sender, amount0, amount1);
    }
    
    function burn(address to) external lock returns (uint amount0, uint amount1) {
        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        address _token0 = token0;                                // gas savings
        address _token1 = token1;                                // gas savings
        uint balance0 = IERC20(_token0).balanceOf(address(this));
        uint balance1 = IERC20(_token1).balanceOf(address(this));
        uint liquidity = balanceOf[address(this)];
        
        bool feeOn = _mintFee(_reserve0, _reserve1);
        uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
        amount0 = liquidity.mul(balance0) / _totalSupply; // using balances ensures pro-rata distribution
        amount1 = liquidity.mul(balance1) / _totalSupply; // using balances ensures pro-rata distribution
        require(amount0 > 0 && amount1 > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED');
        _burn(address(this), liquidity);
        _safeTransfer(_token0, to, amount0);
        _safeTransfer(_token1, to, amount1);
        balance0 = IERC20(_token0).balanceOf(address(this));
        balance1 = IERC20(_token1).balanceOf(address(this));
        
        _update(balance0, balance1, _reserve0, _reserve1);
        if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
        emit Burn(msg.sender, amount0, amount1, to);
    }
    
    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
        require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');
        
        uint balance0;
        uint balance1;
        { // scope for _token{0,1}, avoids stack too deep errors
        address _token0 = token0;
        address _token1 = token1;
        require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
        if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
        if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
        if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
        balance0 = IERC20(_token0).balanceOf(address(this));
        balance1 = IERC20(_token1).balanceOf(address(this));
        }
        uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
        uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
        require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT');
        { // scope for reserve{0,1}Adjusted, avoids stack too deep errors
        uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
        uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
        require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
        }
        
        _update(balance0, balance1, _reserve0, _reserve1);
        emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
    }
}

2. Price Oracle Integration

Implement TWAP (Time-Weighted Average Price) oracles:

contract UniswapV2Oracle {
    using FixedPoint for *;
    
    struct Observation {
        uint timestamp;
        uint price0Cumulative;
        uint price1Cumulative;
    }
    
    mapping(address => Observation) public observations;
    
    function update(address tokenA, address tokenB) external {
        address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB);
        
        Observation storage observation = observations[pair];
        uint timeElapsed = block.timestamp - observation.timestamp;
        
        if (timeElapsed > 0) {
            (uint price0Cumulative, uint price1Cumulative,) = UniswapV2OracleLibrary.currentCumulativePrices(pair);
            observation.timestamp = block.timestamp;
            observation.price0Cumulative = price0Cumulative;
            observation.price1Cumulative = price1Cumulative;
        }
    }
    
    function consult(address token, uint amountIn, address tokenA, address tokenB) external view returns (uint amountOut) {
        address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB);
        Observation memory observation = observations[pair];
        
        uint timeElapsed = block.timestamp - observation.timestamp;
        require(timeElapsed >= PERIOD, 'UniswapV2Oracle: PERIOD_NOT_ELAPSED');
        
        uint price0Cumulative = observation.price0Cumulative;
        uint price1Cumulative = observation.price1Cumulative;
        
        (uint price0CumulativeCurrent, uint price1CumulativeCurrent,) = UniswapV2OracleLibrary.currentCumulativePrices(pair);
        
        uint price0Average = FixedPoint.uq112x112(uint224((price0CumulativeCurrent - price0Cumulative) / timeElapsed));
        uint price1Average = FixedPoint.uq112x112(uint224((price1CumulativeCurrent - price1Cumulative) / timeElapsed));
        
        if (token == tokenA) {
            return price0Average.mul(amountIn).decode144();
        } else {
            return price1Average.mul(amountIn).decode144();
        }
    }
}

Building a Lending Protocol

1. Core Lending Logic

contract CompoundLikeLending {
    using SafeMath for uint256;
    
    struct Market {
        bool isListed;
        uint256 collateralFactorMantissa;
        bool isComped;
    }
    
    mapping(address => Market) public markets;
    mapping(address => uint256) public borrowCaps;
    
    function enterMarkets(address[] calldata cTokens) external returns (uint[] memory) {
        uint len = cTokens.length;
        uint[] memory results = new uint[](len);
        
        for (uint i = 0; i < len; i++) {
            address cToken = cTokens[i];
            Market storage marketToEnter = markets[cToken];
            
            if (!marketToEnter.isListed) {
                results[i] = uint(Error.MARKET_NOT_LISTED);
                continue;
            }
            
            if (markets[cToken].isListed) {
                results[i] = uint(Error.NO_ERROR);
                continue;
            }
            
            results[i] = uint(Error.NO_ERROR);
            markets[cToken] = Market(true, 0, false);
        }
        
        return results;
    }
    
    function exitMarket(address cToken) external returns (uint) {
        uint allowed = redeemAllowedInternal(cToken, msg.sender, 0);
        if (allowed != uint(Error.NO_ERROR)) {
            return allowed;
        }
        
        Market storage marketToExit = markets[cToken];
        
        if (!marketToExit.isListed) {
            return uint(Error.MARKET_NOT_LISTED);
        }
        
        uint borrowBalance = borrowBalanceStoredInternal(msg.sender);
        
        if (borrowBalance != 0) {
            return uint(Error.NONZERO_BORROW_BALANCE);
        }
        
        uint tokenBalance = CToken(cToken).balanceOf(msg.sender);
        uint redeemTokens = tokenBalance;
        
        uint redeemResult = redeemAllowed(cToken, msg.sender, redeemTokens);
        if (redeemResult != uint(Error.NO_ERROR)) {
            return redeemResult;
        }
        
        return uint(Error.NO_ERROR);
    }
    
    function getAccountLiquidity(address account) public view returns (uint, uint, uint) {
        (uint err, uint liquidity, uint shortfall) = getHypotheticalAccountLiquidityInternal(account, address(0), 0, 0);
        
        return (err, liquidity, shortfall);
    }
    
    function getHypotheticalAccountLiquidityInternal(
        address account,
        address cTokenModify,
        uint redeemTokens,
        uint borrowAmount
    ) internal view returns (uint, uint, uint) {
        
        AccountLiquidityLocalVars memory vars;
        uint oErr;
        
        CToken[] memory assets = accountAssets[account];
        for (uint i = 0; i < assets.length; i++) {
            CToken asset = assets[i];
            
            (oErr, vars.cTokenBalance, vars.borrowBalance, vars.exchangeRateMantissa) = asset.getAccountSnapshot(account);
            if (oErr != 0) {
                return (oErr, 0, 0);
            }
            vars.collateralFactor = Exp({mantissa: markets[address(asset)].collateralFactorMantissa});
            vars.exchangeRate = Exp({mantissa: vars.exchangeRateMantissa});
            
            vars.sumCollateral = mul_ScalarTruncateAddUInt(vars.collateralFactor, vars.cTokenBalance, vars.sumCollateral);
            
            vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.exchangeRate, vars.borrowBalance, vars.sumBorrowPlusEffects);
            
            if (asset == CToken(cTokenModify)) {
                vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.exchangeRate, redeemTokens, vars.sumBorrowPlusEffects);
                vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(Exp({mantissa: vars.borrowBalance}), borrowAmount, vars.sumBorrowPlusEffects);
            }
        }
        
        if (vars.sumCollateral > vars.sumBorrowPlusEffects) {
            return (uint(Error.NO_ERROR), vars.sumCollateral - vars.sumBorrowPlusEffects, 0);
        } else {
            return (uint(Error.NO_ERROR), 0, vars.sumBorrowPlusEffects - vars.sumCollateral);
        }
    }
}

2. Interest Rate Model

contract InterestRateModel {
    using SafeMath for uint256;
    
    uint256 public constant blocksPerYear = 2102400;
    
    function getBorrowRate(uint256 cash, uint256 borrows, uint256 reserves) external view returns (uint256) {
        uint256 util = utilizationRate(cash, borrows, reserves);
        
        if (util <= kink) {
            return baseRate.add(util.mul(multiplier).div(1e18));
        } else {
            uint256 normalRate = baseRate.add(kink.mul(multiplier).div(1e18));
            uint256 excessUtil = util.sub(kink);
            return normalRate.add(excessUtil.mul(jumpMultiplier).div(1e18));
        }
    }
    
    function getSupplyRate(uint256 cash, uint256 borrows, uint256 reserves, uint256 reserveFactorMantissa) external view returns (uint256) {
        uint256 oneMinusReserveFactor = uint256(1e18).sub(reserveFactorMantissa);
        uint256 borrowRate = getBorrowRate(cash, borrows, reserves);
        uint256 rateToPool = borrowRate.mul(oneMinusReserveFactor).div(1e18);
        return utilizationRate(cash, borrows, reserves).mul(rateToPool).div(1e18);
    }
    
    function utilizationRate(uint256 cash, uint256 borrows, uint256 reserves) public pure returns (uint256) {
        if (borrows == 0) {
            return 0;
        }
        
        return borrows.mul(1e18).div(cash.add(borrows).sub(reserves));
    }
}

Security Best Practices

1. Reentrancy Protection

contract ReentrancyGuard {
    uint256 private constant _NOT_ENTERED = 1;
    uint256 private constant _ENTERED = 2;
    
    uint256 private _status;
    
    constructor() {
        _status = _NOT_ENTERED;
    }
    
    modifier nonReentrant() {
        require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
        _status = _ENTERED;
        _;
        _status = _NOT_ENTERED;
    }
}

2. Access Control

contract AccessControl {
    using EnumerableSet for EnumerableSet.AddressSet;
    
    bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00;
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
    
    mapping(bytes32 => EnumerableSet.AddressSet) private _roleMembers;
    mapping(bytes32 => RoleData) private _roles;
    
    struct RoleData {
        mapping(address => bool) members;
        bytes32 adminRole;
    }
    
    modifier onlyRole(bytes32 role) {
        _checkRole(role, _msgSender());
        _;
    }
    
    function hasRole(bytes32 role, address account) public view returns (bool) {
        return _roles[role].members[account];
    }
    
    function _checkRole(bytes32 role, address account) internal view {
        if (!hasRole(role, account)) {
            revert(
                string(
                    abi.encodePacked(
                        "AccessControl: account ",
                        Strings.toHexString(uint160(account), 20),
                        " is missing role ",
                        Strings.toHexString(uint256(role), 32)
                    )
                )
            );
        }
    }
}

3. Integer Overflow Protection

library SafeMath {
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 c = a + b;
        require(c >= a, "SafeMath: addition overflow");
        return c;
    }
    
    function sub(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b <= a, "SafeMath: subtraction overflow");
        uint256 c = a - b;
        return c;
    }
    
    function mul(uint256 a, uint256 b) internal pure returns (uint256) {
        if (a == 0) {
            return 0;
        }
        uint256 c = a * b;
        require(c / a == b, "SafeMath: multiplication overflow");
        return c;
    }
    
    function div(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b > 0, "SafeMath: division by zero");
        uint256 c = a / b;
        return c;
    }
}

Testing and Auditing

1. Unit Testing with Foundry

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
 
import "forge-std/Test.sol";
import "../src/UniswapV2Pair.sol";
 
contract UniswapV2PairTest is Test {
    UniswapV2Pair pair;
    address token0;
    address token1;
    
    function setUp() public {
        token0 = address(new MockERC20("Token0", "T0"));
        token1 = address(new MockERC20("Token1", "T1"));
        pair = new UniswapV2Pair();
    }
    
    function testMint() public {
        uint256 amount0 = 1000;
        uint256 amount1 = 2000;
        
        MockERC20(token0).mint(address(pair), amount0);
        MockERC20(token1).mint(address(pair), amount1);
        
        uint256 liquidity = pair.mint(address(this));
        
        assertTrue(liquidity > 0);
        assertEq(pair.totalSupply(), liquidity);
    }
    
    function testSwap() public {
        // Setup initial liquidity
        testMint();
        
        uint256 amount0Out = 100;
        uint256 amount1Out = 0;
        
        uint256 balance0Before = MockERC20(token0).balanceOf(address(this));
        uint256 balance1Before = MockERC20(token1).balanceOf(address(this));
        
        pair.swap(amount0Out, amount1Out, address(this), "");
        
        uint256 balance0After = MockERC20(token0).balanceOf(address(this));
        uint256 balance1After = MockERC20(token1).balanceOf(address(this));
        
        assertEq(balance0After - balance0Before, amount0Out);
        assertTrue(balance1Before - balance1After > 0);
    }
}

2. Integration Testing

import { ethers } from "hardhat";
import { expect } from "chai";
 
describe("DeFi Protocol Integration", () => {
  let owner: any;
  let user1: any;
  let user2: any;
  let token: any;
  let lending: any;
  
  beforeEach(async () => {
    [owner, user1, user2] = await ethers.getSigners();
    
    const Token = await ethers.getContractFactory("MockERC20");
    token = await Token.deploy("Test Token", "TEST");
    
    const Lending = await ethers.getContractFactory("LendingProtocol");
    lending = await Lending.deploy(token.address);
  });
  
  it("should allow users to deposit and borrow", async () => {
    // User deposits tokens
    await token.connect(user1).approve(lending.address, 1000);
    await lending.connect(user1).deposit(1000);
    
    // User borrows against collateral
    await lending.connect(user1).borrow(500);
    
    const userBalance = await lending.getUserBalance(user1.address);
    expect(userBalance).to.equal(500);
  });
  
  it("should liquidate undercollateralized positions", async () => {
    // Setup scenario for liquidation
    await token.connect(user1).approve(lending.address, 1000);
    await lending.connect(user1).deposit(1000);
    await lending.connect(user1).borrow(800);
    
    // Price drops, triggering liquidation
    await lending.connect(user2).liquidate(user1.address, 400);
    
    const userBalance = await lending.getUserBalance(user1.address);
    expect(userBalance).to.be.lessThan(400);
  });
});

Conclusion

Building secure DeFi protocols requires careful attention to security, gas optimization, and protocol design. By following these best practices and implementing proper testing and auditing procedures, you can create robust and secure DeFi applications.

Key takeaways:

For more advanced topics, explore my guides on cross-chain DeFi and advanced protocol design patterns.

Resources