一、库合约的使用
官网示例中给出了一个自定义的library,然后直接在合约中引用此library,这个自定义的Balances library实现的是转账功能,在转账之前对金额做校验,保障转出账户不会为负,转账金额不会为负。
1. 使用示例
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
library Balances {
function move(mapping(address => uint256) storage balances, address from, address to, uint amount) internal {
require(balances[from] >= amount);
require(balances[to] + amount >= balances[to]);
balances[from] -= amount;
balances[to] += amount;
}
}
contract Token {
mapping(address => uint256) balances;
using Balances for *; // 引入库
mapping(address => mapping (address => uint256)) allowed;
event Transfer(address from, address to, uint amount);
event Approval(address owner, address spender, uint amount);
function transfer(address to, uint amount) external returns (bool success) {
balances.move(msg.sender, to, amount);
emit Transfer(msg.sender, to, amount);
return true;
}
function transferFrom(address from, address to, uint amount) external returns (bool success) {
require(allowed[from][msg.sender] >= amount);
allowed[from][msg.sender] -= amount;
balances.move(from, to, amount); // 使用了库方法
emit Transfer(from, to, amount);
return true;
}
function approve(address spender, uint tokens) external returns (bool success) {
require(allowed[msg.sender][spender] == 0, "");
allowed[msg.sender][spender] = tokens;
emit Approval(msg.sender, spender, tokens);
return true;
}
function balanceOf(address tokenOwner) external view returns (uint balance) {
return balances[tokenOwner];
}
}
依然按照老的习惯,以测试的方式来调用合约。
2.用例设计
设计下面一组用例,用以调用上述合约,观察库合约中的代码是否生效
- A通过transfer给B转账3个以太,预期结果:转账成功,A的账户余额减少3个以太,B余额增加3个以太
- A通过transfer给B转账0个以太,预期结果:转账成功,A和B账户余额不变
A通过transfer给B转账value为-2,预期结果:转账失败,A和B账户余额不变- A给B授权3个以太,B通过transferFrom从A钱包中提现3个以太,预期结果:转账成功,A的账户余额减少3个以太,B余额增加3个以太
- A给B授权0个以太,B通过transferFrom从A钱包中提现0个以太,预期结果:转账成功,A和B账户余额不变
A通过transferFrom给B转账value为-2,预期结果:转账失败,A和B账户余额不变
(由于transfer和transferFrom中定义的金额amount为uint类型,这里无法传入负数。所以上述用例里的第3,6个用例在不修改合约的情况下无法实现。)
3.实现说明
(1)由于在示例合约中,没有定义构造函数,无法给balances的任意成员赋值,因此增加了如下构造函数,便于使用:
uint256 totalSupply_ = 100 ether;
constructor() {
balances[msg.sender] = totalSupply_;
}
(2)由于library库中对转账金额判断后,没有给出具体的错误提示,不便于判断具体场景,因此添加revert的信息:
require(balances[from] >= amount, "balance not enough");
require(balances[to] + amount >= balances[to],"transfer amount is negative");
(3)由于transfer和transferFrom中定义的金额amount为uint类型,这里无法传入负数。所以上述用例里的第3,6个用例在不修改合约的情况下无法实现。
二、合约继承
1.ERC-20协议
不难看出上述示例合约想要实现的功能就是同质化代币转账操作,在ERC-20标准合约协议中已经统一定义好了发行同质化代币需要遵循的开发约定。在实际开发过程中,开发者不需要每次都自己从头设计一套完整的发行代币的合约,而是可以通过引入OpenZeppelin中的ERC-20合约后再自定义开发扩展额外需要的功能。ERC-20标准大大减少了开发者的工作量,并且统一了代币发行规范。
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
function transfer(address recipient, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
其中,有三个只读函数:
- totalSupply()是一个外部只读函数,函数的调用不会改变合约的状态,它返回的是代币发行总量。
- balanceOf()也是一个外部只读函数,函数的调用不会改变合约的状态,它返回的是传入参数地址account中的账户余额。
- allowance()返回的是,owner允许spender代表自己可支配的代币数量
另外三个函数的调用将会改变合约的变量状态:
- transfer():此函数会将amount金额的代币从调用者账户(msg.sender)转移给接受者账户(recipient),转移成功返回true,并且会发送一个Transfer事件
- approve(): 此函数将一定金额amount的代币支配权由调用者(msg.sender)分配给spender,分配成功则返回true,并且会发送一个Approval事件
- transferFrom():此函数利用授权机制,将amount金额的代币从sender账户转移到recipient账户,转移成功则返回true,并且会发送一个Transfer事件
事件的定义:
- Transfer事件:当代币在账户之间进行转移时,就会触发此事件。如果一种新代币在mint时,此事件的from address将是0地址0x00..0000,如果销毁代币时,此事件的接收地址也是0地址0x00..0000
- Approval事件:当发生代币授权行为时,会触发此事件。
2.基于ERC-20的代币合约的示例
pragma solidity ^0.8.0;
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
function transfer(address recipient, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
contract ERC20Basic is IERC20 {
string public constant name = "ERC20Basic";
string public constant symbol = "ERC";
uint8 public constant decimals = 18;
mapping(address => uint256) balances;
mapping(address => mapping (address => uint256)) allowed;
uint256 totalSupply_ = 10 ether;
constructor() {
balances[msg.sender] = totalSupply_;
}
function totalSupply() public override view returns (uint256) {
return totalSupply_;
}
function balanceOf(address tokenOwner) public override view returns (uint256) {
return balances[tokenOwner];
}
function transfer(address receiver, uint256 numTokens) public override returns (bool) {
require(numTokens <= balances[msg.sender]);
balances[msg.sender] = balances[msg.sender]-numTokens;
balances[receiver] = balances[receiver]+numTokens;
emit Transfer(msg.sender, receiver, numTokens);
return true;
}
function approve(address delegate, uint256 numTokens) public override returns (bool) {
allowed[msg.sender][delegate] = numTokens;
emit Approval(msg.sender, delegate, numTokens);
return true;
}
function allowance(address owner, address delegate) public override view returns (uint) {
return allowed[owner][delegate];
}
function transferFrom(address owner, address buyer, uint256 numTokens) public override returns (bool) {
require(numTokens <= balances[owner]);
require(numTokens <= allowed[owner][msg.sender]);
balances[owner] = balances[owner]-numTokens;
allowed[owner][msg.sender] = allowed[owner][msg.sender]-numTokens;
balances[buyer] = balances[buyer]+numTokens;
emit Transfer(owner, buyer, numTokens);
return true;
}
}
另一个被广泛采用的是OZ(OpenZeppelin)对ERC-20协议的实现,其源码地址:github.com/OpenZeppeli…,当开发者自己要发一套代币时,可以选择直接引用OZ库中的合约,使用方法如下:
pragma solidity ^0.8.0;
import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
contract Token is ERC20, Ownable {
constructor() ERC20("Token demo", "TESTER") {}
function mint(address account, uint256 amount) public onlyOwner {
_mint(account, amount);
}
// add other functions
}
3.学习借鉴
在OZ实现的ERC20合约中,有一些值得学习借鉴的地方:
(1)继承性
以transfer函数为例,加了virtual关键字,这样方便子合约可以继承后重写transfer。真正实现token transfer功能的是_transfer函数,可以看到在token转移前后,各有一个virtual函数,_beforeTokenTransfer和_afterTokenTransfer,这两个函数在OZ的合约中没有具体的实现,其实就是为了方便开发者引入库合约后,自定义开发额外的功能,在transfer通用逻辑处预设了hooks。
function transfer(address to, uint256 amount) public virtual override returns (bool) {
address owner = _msgSender();
_transfer(owner, to, amount);
return true;
}
function _transfer(address from, address to, uint256 amount) internal virtual {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(from, to, amount);
uint256 fromBalance = _balances[from];
require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
unchecked {
_balances[from] = fromBalance - amount;
// Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by
// decrementing then incrementing.
_balances[to] += amount;
}
emit Transfer(from, to, amount);
_afterTokenTransfer(from, to, amount);
}
function _beforeTokenTransfer(
address from,
address to,
uint256 amount
) internal virtual {}
function _afterTokenTransfer(
address from,
address to,
uint256 amount
) internal virtual {}
另外_msgSender()实际上是import "../../utils/Context.sol“中的函数,返回的值是msg.sender
abstract contract Context {
function _msgSender() internal view virtual returns (address) {
return msg.sender;
}
function _msgData() internal view virtual returns (bytes calldata) {
return msg.data;
}
}
有一个疑问是:为什么用Context.sol中的_msgSender()方法,而不是直接用msg.sender呢?
在网上搜索了一下答案,看到一种解释——这么做也是为了方便拓展,实现自定义功能而采用了virtual的_msgSender(),如果不需要对_msgSender()进行自定义开发,那用msg.sender就可以了。
(2)Gas优化
在上面transfer代码中,在对from账户进行余额扣减,对to账户进行余额增加时,OZ的作法是,先判断from账户的余额是大于转出金额的,然后用一段unchecked代码进行余额变更。这样的写法会跳过整数溢出的检查从而节省一定的gas费用,但需要注意的是一定要先判断余额然后再使用unchecked代码段。
(3)安全设计
在ERC-20协议中有提示approve函数存在安全隐患,攻击者可以通过设计好的攻击序列,获得比授权额度更多的代币金额。要了解这个设计漏洞详情可以查看:docs.google.com/document/d/…
OZ在遵循ERC20协议的前提下,在安全方面做了一些改进,增加了两个在ERC20标准协议里没有定义的函数:
function increaseAllowance(address spender, uint256 addedValue) public virtual returns (bool) {
address owner = _msgSender();
_approve(owner, spender, allowance(owner, spender) + addedValue);
return true;
}
function decreaseAllowance(address spender, uint256 subtractedValue) public virtual returns (bool) {
address owner = _msgSender();
uint256 currentAllowance = allowance(owner, spender);
require(currentAllowance >= subtractedValue, "ERC20: decreased allowance below zero");
unchecked {
_approve(owner, spender, currentAllowance - subtractedValue);
}
return true;
}
这两个方法不能完全规避掉ERC20协议自带的“抢跑”问题,但是能从一定程度上缓解此问题。比如下面的场景: 这两个方法不能完全规避掉ERC20协议自带的“抢跑”问题,但是能从一定程度上缓解此问题。比如下面的场景:
- A授权给B 100个token
- A改变想法,只想给B 30个token
在原ERC20协议里,A需要发两次交易:
- approve(b.address, 100)
- approve(b.address,30)
如果B在A的两次交易之间进行一次提现100的操作:transferFrom(a.address,b.address,100),等待A的第二个交易请求执行成功后再提现30个token,那么实际上B从A那里提走的是130个token,而这显然不是A的意愿。
如果采用decreaseAllowance方法会怎样呢?A在这种情况下也需要发送两个交易请求:
- approve(b.address, 100)
- decreaseAllowance(b.address,70)
B还是在A的两次交易之间进行提现100的操作,能够提现成功。此时轮到A的第二个交易进行处理,会发现currentAllowance已经变成0,无法再为B更改可提现金额,这种情况下,B最多只能从A那里提走100个token,对A来说减少了一些损失。
(4)拓展设计
OZ除了上述对ERC20标准进行了实现和拓展之外,还增加了一些有代表性的拓展功能,比如可销毁、可暂停等功能,具体可参考官方文档(docs.openzeppelin.com/contracts/4…)
三、小结
在开发智能合约的时候,善用合约库,以及学习和利用像OpenZeppelin这样的经过充分验证的开源智能合约库,能够帮助我们更好地进行功能的实现,高效完成需求的开发,同时开源智能合约库的设计也有很多巧妙之处,值得学习借鉴,帮助提升自身的合约设计开发能力。