# 区块链开发课第三讲 智能合约开发(1) **Published by:** [yueying007](https://paragraph.com/@yueying007/) **Published on:** 2022-04-16 **URL:** https://paragraph.com/@yueying007/1 ## Content 这节课中,我会带你使用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.solfunction 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.solfunction 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 ## Publication Information - [yueying007](https://paragraph.com/@yueying007/): Publication homepage - [All Posts](https://paragraph.com/@yueying007/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@yueying007): Subscribe to updates