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
- Liquidity Pools: Automated market makers (AMMs)
- Lending Protocols: Decentralized lending and borrowing
- Derivatives: Synthetic assets and options
- Yield Farming: Automated yield generation
- Cross-chain Bridges: Interoperability solutions
Security Considerations
- Smart Contract Audits: Professional security reviews
- Formal Verification: Mathematical proof of correctness
- Bug Bounty Programs: Community-driven security testing
- Multi-signature Wallets: Decentralized governance
- Time Locks: Delayed execution for critical operations
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:
- Implement proper access controls and reentrancy protection
- Use established patterns like AMMs and lending protocols
- Integrate price oracles for accurate valuations
- Write comprehensive tests and conduct security audits
- Follow the principle of least privilege
- Implement proper error handling and validation
For more advanced topics, explore my guides on cross-chain DeFi and advanced protocol design patterns.