yueying007

Posted on Apr 15, 2022Read on Mirror.xyz

区块链开发课第三讲 智能合约开发(1)

这节课中,我会带你使用Solidity编写一个简单的智能合约,实现闪电贷的功能。

在开始之前,你可以在本地建立一个目录,从github上下载代码,打开IDE,对照着代码学习。

mkdir ~/Projects
cd ~/Projects
git clone https://github.com/yueying007/blockchainclass.git

打开SimpleArbi.sol,我们来学习一下Solidity的基本用法。

SimpleArbi.sol

首先,在头部我们定义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。

fallback

在solidity 0.6.x版本以后,fallback函数分为两种:

fallback(): 当从外部调用此合约时,在合约中没有找到函数名,就会自动调用该函数

receive(): 用来接受空的外部调用(call())或者接收ETH

记住,如果不加上receive(),我们的合约是无法接收外部的ETH转账的:

receive() external payable {}

注:payable关键字:在调用函数的时候可以附带发送ETH。

访问权限

你可能注意到,无论是状态变量,还是函数,都有一个关键字来定义访问权限:

external: 只允许从合约外部访问

public: 既可以从合约外部访问,也可以从合约内部访问

internal: 只能从合约内部,或者从继承合约访问

private: 只能从合约内部访问,不能从继承合约访问

get/set

接下来我们定义一些函数用来读取信息或者发起交易:

// 返回合约的所有者
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的合约:

LiquidityPool.sol

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()函数是什么。

BorrowerProxy.sol

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