区块链开发课第三讲 智能合约开发(1)
April 16th, 2022

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

Subscribe to yueying007
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from yueying007

Skeleton

Skeleton

Skeleton