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

By [yueying007](https://paragraph.com/@yueying007) · 2022-04-16

---

这节课中，我会带你使用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_

---

*Originally published on [yueying007](https://paragraph.com/@yueying007/1)*
