这节课中,我会带你使用Solidity编写一个简单的智能合约,实现闪电贷的功能。
在开始之前,你可以在本地建立一个目录,从github上下载代码,打开IDE,对照着代码学习。
mkdir ~/Projects
cd ~/Projects
git clone https://github.com/yueying007/blockchainclass.git
打开SimpleArbi.sol,我们来学习一下Solidity的基本用法。
首先,在头部我们定义solidity的版本号:
pragma solidity 0.8.0;
在执行一笔交易时,合约往往需要调用外部合约,因此需要定义外部合约的接口(interface),最典型的是ERC20标准token的接口:
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address recipient, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
function decimals() external view returns (uint8);
}
一个接口包含function关键字、函数名、参数列表、external关键字以及返回值类型。通过接口,合约可以与外部合约进行交互,而不需要知道外部合约具体的实现细节。
带view关键字的函数,表示只读函数,即只可以读取区块链的状态,而不可以改变状态,属于静态调用;不带view关键字的函数,可以进行改写状态变量、发送事件、转账ETH等等这些可以改变状态的调用,这类调用也可以称作一笔交易(transaction)。
在IERC20接口中,可以通过总供应量(totalSupply)、余额(balanceOf)、数位(decimals)等只读函数获取token的信息,也可以通过转账(transfer)、请求转账(transferFrom)、授权(approve)等函数发起交易。
下面看看WETH接口:
interface IWETH {
function deposit() external payable;
function withdraw(uint wad) external;
}
WETH(Wrapped Ether),是一种将以太坊原生代币ETH与ERC20token互相转换的合约。在WETH接口中,定义了两个函数:
deposit: 将ETH转换为WETH
withdraw: 将WETH转换为ETH
由于要实现闪电贷,我们需要与KeeperDao的LiquidityPool合约交互,所以需要加上LiquidityPool的接口:
interface ILiquidity {
function borrow(address _token, uint256 _amount, bytes calldata _data) external;
}
在写合约时经常需要用到一些库,比如最常用的SafeMath库。
在solidity中,通常会用uint256类型定义token的数量。uint256是一种非负整型变量,在进行加减乘数/取余/取模运算时,如果不小心就会溢出,所以在对uint256进行数学运算时,应当尽量用add/sub/mul/div来代替+-*/。
library SafeMath {
function sub(uint256 a, uint256 b) internal pure returns (uint256) {
return sub(a, b, "SafeMath: subtraction overflow");
}
function sub(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) {
require(b <= a, errorMessage);
uint256 c = a - b;
return c;
}
下面来到主体部分,合约(contract)。
solidity是一种面向对象的编程语言,用contract关键字定义一个合约,它类似于我们熟悉的类(class),而部署一个合约相当于为这个类实例化一个对象。
一个类包含属性与方法,一个合约包含状态变量(state variable)与函数(function)。在函数内部定义的变量以及函数的参数称为局部变量(local variable)。状态变量与局部变量的区别在于,状态变量存储在区块链上,因此任何改写状态变量的操作都是一笔transaction,需要消耗gas,而局部变量只在内存中。因此,为了节省交易成本,我们应尽量少地去更改状态变量的值,而多用传参或者定义局部变量来完成计算。
首先我们定义一个结构体类型,用来存储借的token以及借的数量:
struct RepayData {
address repay_token;
uint256 repay_amount;
}
然后定义一些基本的地址:
address owner;
address liquidityPool = 0x4F868C1aa37fCf307ab38D215382e88FCA6275E2;
address borrowerProxy = 0x17a4C8F43cB407dD21f9885c5289E66E21bEcD9D;
address WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
然后来到构造函数:
constructor () public {
owner = address(tx.origin);
}
构造函数只有在合约部署时被调用,在里面初始化一些状态变量。在这里,我们定义合约的所有者owner是部署合约这笔交易的源头(tx.origin)
修改器(modifier)是一种用来修改其它函数的函数,它可以包在其它函数外面,实现额外的功能。
我们定义一个onlyOwner()函数, 它要求函数的调用者(msg.sender)只能是合约的所有者(owner)。
modifier onlyOwner(){
require(address(msg.sender) == owner, "No authority");
_;
}
我们注意到这里有一个require函数,它类似于:
if (address(msg.sender) != owner) revert("No authority");
意思是如果不满足某个条件,则立即回滚到调用前的初始状态。如果一笔交易回滚,就相当于交易没有发生,这是一种原子操作,即要么成功,要么失败,没有中间状态。我们通常用require对函数的传参进行检查。记住,回滚的交易仍然会消耗gas。
在solidity 0.6.x版本以后,fallback函数分为两种:
fallback(): 当从外部调用此合约时,在合约中没有找到函数名,就会自动调用该函数
receive(): 用来接受空的外部调用(call())或者接收ETH
记住,如果不加上receive(),我们的合约是无法接收外部的ETH转账的:
receive() external payable {}
注:payable关键字:在调用函数的时候可以附带发送ETH。
你可能注意到,无论是状态变量,还是函数,都有一个关键字来定义访问权限:
external: 只允许从合约外部访问
public: 既可以从合约外部访问,也可以从合约内部访问
internal: 只能从合约内部,或者从继承合约访问
private: 只能从合约内部访问,不能从继承合约访问
接下来我们定义一些函数用来读取信息或者发起交易:
// 返回合约的所有者
function getOwner() public view returns(address) {
return owner;
}// 返回某个账户的某个token的余额
function getTokenBalance(address token, address account) public view returns(uint256) {
return IERC20(token).balanceOf(account);
}// 从合约转出ETH
function turnOutETH(uint256 amount) public onlyOwner {
payable(owner).transfer(amount);
}// 从合约转出token
function turnOutToken(address token, uint256 amount) public onlyOwner {
IERC20(token).transfer(owner, amount);
}// WETH转换为ETH
function WETHToETH(uint256 amount) public onlyOwner {
IWETH(WETH).withdraw(amount);
}// ETH转换为WETH
function ETHtoWETH(uint256 amount) public onlyOwner {
IWETH(WETH).deposit{value:amount}();
}
注意,在get函数中,我们用view关键字表示只读。而在set函数中,我们加上了onlyOwner修改器,防止函数被所有者以外的人调用。
在任何时候编写合约,我都建议你加上turnOutETH和turnOutToken这两个函数,如果没加,合约里如果有ETH或者token,就会被永远锁在里面出不来了。
在实现闪电贷之前,我们先来熟悉一下KeerDao的合约:
function borrow(address _token, uint256 _amount, bytes calldata _data) external nonReentrant whenNotPaused {
require(address(kTokens[_token]) != address(0x0), "Token is not registered");
uint256 initialBalance = borrowableBalance(_token);
_transferOut(_msgSender(), _token, _amount);
borrower.lend(_msgSender(), _data);
uint256 finalBalance = borrowableBalance(_token);
require(finalBalance >= initialBalance, "Borrower failed to return the borrowed funds");
uint256 fee = finalBalance - initialBalance;
uint256 poolFee = calculateFee(poolFeeInBips, fee);
emit Borrowed(_msgSender(), _token, _amount, fee);
_transferOut(feePool, _token, poolFee);
}
首先我通过调用LiquidityPool的borrow()发起一笔闪电贷,通过参数_token和_amount告诉它我要借什么token以及借多少。可以看到,在进行参数检查后,它会首先通过_transferOut()向我发送数量为_amount的_token,这时我就已经收到了这笔贷款。然后它会调用borrower的lend()。我们再来看看这个lend()函数是什么。
function lend(address _caller, bytes calldata _data) external payable {
require(msg.sender == liquidityPool, "BorrowerProxy: Caller is not the liquidity pool");
(bool success,) = _caller.call{ value: msg.value }(_data);
require(success, "BorrowerProxy: Borrower contract reverted during execution");
}
在lend()函数中,它首先检查调用方必须是LquidityPool,然后向_caller(就是我)发起一个回调call(_data)。要知道向其它合约发送call()就相当于调用其它合约的函数,而这里的_data是一段加密的bytes,包含了函数名和参数的信息。我收到回调后,会完成一系列套利操作,然后立即归还这笔贷款,因为接下来在borrow()函数中,它会检查贷款是否还清:
uint256 finalBalance = borrowableBalance(_token);
require(finalBalance >= initialBalance, "Borrower failed to return the borrowed funds");
如果没有还清,它会立即回滚,使整个交易失败。
此时这笔闪电贷的流程就明确了:
我调用LiquidityPool发起闪电贷 => LiquidityPool释放贷款 =>BorrowerProxy回调我=>我归还贷款
熟悉了流程之后,我们首先定义一个flashLoan函数发起闪电贷:
function flashLoan(address token, uint256 amount) public {
RepayData memory _repay_data = RepayData(token, amount);
ILiquidity(liquidityPool).borrow(token, amount,
abi.encodeWithSelector(this.receiveLoan.selector, abi.encode(_repay_data)));
}
函数有两个参数,要借的token的地址(token)以及数量(amount)。然后定义一个局部变量_repay_data存储还款信息。我们用abi.encodeWithSelector把回调函数的名字以及还款信息加密成一串bytes类型的data,连同token,amount作为参数调用LiquidityPool的borrow(),发起一笔闪电贷。
接下来我需要在合约里定义一个回调函数receiveLoan用来进行接收到贷款后的操作:
// callback
function receiveLoan(bytes memory data) public {
require(msg.sender == borrowerProxy, "Not borrower");
RepayData memory _repay_data = abi.decode(data, (RepayData));
IERC20(_repay_data.repay_token).transfer(liquidityPool, _repay_data.repay_amount);
}
首先检查一下调用者必须是BorrorwerProxy合约,防止被其他人恶意调用。
然后将data解码,获得还款token和还款数量。
这里,我们先不做任何操作(以后需要在这里执行套利操作),直接将token转给LiquidityPool,一笔闪电贷就完成了。
至此,我们完成了一个简单的智能合约,实现了闪电贷的功能,在下一讲中,我会带你对这个合约进行测试。
欢迎来即刻App与我互动,即刻账号: 月影007