solidity
错误处理及异常:Assert, Require, Revert
用 assert 检查异常(Panic) 和 require 检查错误(Error)
assert和require可用于检查条件并抛出异常
assert函数会创建一个Painc(uint256)的错误,只用于测试内部错误,检查不变量。
下列情况将会产生一个Panic异常: 错误数据会提供的错误码编号,用来指示Panic的类型:
0x00: 用于常规编译器插入的Panic。
0x01: 如果你调用 assert 的参数(表达式)结果为 false 。
0x11: 在 unchecked { … } 外,如果算术运算结果向上或向下溢出。
0x12; 如果你用零当除数做除法或模运算(例如 5 / 0 或 23 % 0 )。
0x21: 如果你将一个太大的数或负数值转换为一个枚举类型。
0x22: 如果你访问一个没有正确编码的存储byte数组.
0x31: 如果在空数组上 .pop() 。
0x32: 如果你访问 bytesN 数组(或切片)的索引太大或为负数。(例如: x[i] 而 i >= x.length 或 i < 0).
0x41: 如果你分配了太多的内内存或创建了太大的数组。
0x51: 如果你调用了零初始化内部函数类型变量。
assert当参数是false时会抛出panic异常,立即停止执行剩余代码,回滚当前交易所有状态变更(即任何更改不会保存在链上),交易失败所消耗的gas不会返还。
require函数可以创建无错误提示的错误,也可创建一个 Error(string) 类型的错误。
下列情况将会产生一个 Error(string) (或无错误提示)的错误:
如果你调用 require(x) ,而 x 结果为 false 。
如果你使用 revert() 或者 revert(“description”) 。
如果你在不包含代码的合约上执行外部函数调用。
如果你通过合约接收以太币,而又没有 payable 修饰符的公有函数(包括构造函数和 fallback 函数)。
如果你的合约通过公有 getter 函数接收 Ether 。
revert
可以使用revert语句和函数来直接触发回退。
revert 语句将一个自定义的错误作为直接参数,没有括号:
revert CustomError(arg1, arg2);
由于向后兼容,还有一个 revert() 函数,它使用圆括号接受一个字符串:
revert(); revert(“description”);
合约
函数修改器
它们可以在执行函数之前自动检查某个条件。 修改器modifier 是合约的可继承属性,并可能被派生合约覆盖 , 但前提是它们被标记为 virtual.。
// 建立了一个NoteBook的合约,只有NoteBook的拥有者才可以修改其内容record
contract NoteBook{
string public record; // NoteBook的内容
address owner; // NoteBook的拥有者
constructor() {
owner = msg.sender;
}
// 修改record的内容
function changeRecord(string memory _record) public isOwner {
record = _record;
}
// 函数修改器:判断是否是NoteBook的
modifier isOwner{
require(msg.sender == owner, "You are not the owner of this NoteBook");
_;
}
}
上述例子中,我们通过关键字 modifier 后面接函数修改器名 NoteBook 来定义一个modifier。在上述定义的modifier中如果调用者不是拥有者则会停止执行接下来的代码,并在控制台输出自定义的原因。如果是的话则执行到 _ 处,_ 代表使用该modifier的函数体,这里即为changeRecord 函数的函数体。在执行changeRecord 函数前先会使用isOwner进行检查,没有问题后才会执行。
contract modifierOder {
address owner;
uint256 a;
constructor() {
owner = msg.sender;
}
function test(uint num) public checkPara(num) returns(uint256) {
a = 10;
return a;
}
// 修改a
modifier checkPara(uint number) {
a = 1;
_;
a = 100;
}
}
如以上代码所示:在 _后又有一句代码a = 100 。函数执行完return后,后面的代码则不再执行,但是在modifier中,执行完函数体 _ 还会接着执行 a = 100 这条语句。所以尽管函数返回的a 的值为10,但是最后a的值变成了100。
Constant 和 Immutable 状态变量
对于 constant 常量, 他的值在编译器确定,而对于 immutable(不可变量), 它的值在部署时确定。
对于constant状态量,只能使用那些在编译时有确定值的表达式 ,任何通过访问 storage,区块链数据(例如 block.timestamp, address(this).balance 或者 block.number)或执行数据( msg.value 或 gasleft() ) 或对外部合约的调用来给它们赋值都是不允许的。
内建(built-in)函数 keccak256 , sha256 , ripemd160 , ecrecover , addmod 和 mulmod 是允许的(即使他们确实会调用外部合约, keccak256 除外)。
对于immutable,可以在合约的构造函数中或声明时为不可变的变量分配任意值。 不可变量只能赋值一次,并且在赋值之后才可以读取。
状态可变性
view
要求保证不修改状态
下面的语句被认为是修改状态:
修改状态变量。
产生事件。
创建其它合约。
使用 selfdestruct。
通过调用发送以太币。
调用任何没有标记为 view 或者 pure 的函数。
使用低级调用。
使用包含特定操作码的内联汇编。
pure
函数可以声明为 pure ,在这种情况下,承诺不读取也不修改状态变量。
特别是,应该可以在编译时确定一个 pure 函数,它仅处理输入参数和 msg.data ,对当前区块链状态没有任何了解。 这也意味着读取 immutable 变量也不是一个 pure 操作。
除了上面解释的状态修改语句列表之外,以下被认为是读取状态:
读取状态变量。
访问 address(this).balance 或者
访问 block,tx, msg 中任意成员 (除 msg.sig 和 msg.data 之外)。
调用任何未标记为 pure 的函数。
使用包含某些操作码的内联汇编。
Event
在Solidity 代码中,使用event 关键字来定义一个事件,这个用法和定义函数式一样的,并且事件在合约中同样可以被继承。触发一个事件使用emit(说明,之前的版本里并不需要使用emit),触发事件可以在任何函数中调用。
继承
父合约标记为 virtual 函数可以在继承合约里重写(overridden)以更改他们的行为。重写的函数需要使用关键字 override 修饰
重写函数只能将覆盖函数的可见性从 external 更改为 public 。
可变性可以按照以下顺序更改为更严格的一种: nonpayable 可以被 view 和 pure 覆盖。 view 可以被 pure 覆盖。 payable 是一个例外,不能更改为任何其他可变性
如果函数没有标记为 virtual , 那么派生合约将不能更改函数的行为(即不能重写)
对于多重继承,如果有多个父合约有相同定义的函数, override 关键字后必须指定所有父合约名。
如果(重写的)函数继承自一个公共的父合约, override 是可以不用显示指定的
private 的函数是不可以标记为 virtual 的。
除接口之外(因为接口会自动作为 virtual ),没有实现的函数必须标记为 virtual
从 Solidity 0.8.8 开始, 在重写接口函数时不再要求 override 关键字,除非函数在多个父合约定义。
尽管 public 的状态变量可以重写外部函数,但是 public 的状态变量不能被重写。
应用
代理合约
Solidity合约部署在链上之后,代码是不可变的(immutable)。这样既有优点,也有缺点:
优点:安全,用户知道会发生什么(大部分时候)。
坏处:就算合约中存在bug,也不能修改或升级,只能部署新合约。但是新合约的地址与旧的不一样,且合约的数据也需要花费大量gas进行迁移。
有没有办法在合约部署后进行修改或升级呢?答案是有的,那就是代理模式。
代理模式将合约数据和逻辑分开,分别保存在不同合约中。我们拿上图中简单的代理合约为例,数据(状态变量)存储在代理合约中,而逻辑(函数)保存在另一个逻辑合约中。代理合约(Proxy)通过delegatecall,将函数调用全权委托给逻辑合约(Implementation)执行,再把最终的结果返回给调用者(Caller)。
代理模式主要有两个好处:
可升级:当我们需要升级合约的逻辑时,只需要将代理合约指向新的逻辑合约。
省gas:如果多个合约复用一套逻辑,我们只需部署一个逻辑合约,然后再部署多个只保存数据的代理合约,指向逻辑合约。
发送eth
- transfer
用法是接收方地址.transfer(发送ETH数额)。
transfer()的gas限制是2300,足够用于转账,但对方合约的fallback()或receive()函数不能实现太复杂的逻辑。
transfer()如果转账失败,会自动revert(回滚交易)。
代码样例,注意里面的_to填ReceiveETH合约的地址,amount是ETH转账金额:
// 用transfer()发送ETH
function transferETH(address payable _to, uint256 amount) external payable{
_to.transfer(amount);
}
- send
用法是接收方地址.send(发送ETH数额)。
send()的gas限制是2300,足够用于转账,但对方合约的fallback()或receive()函数不能实现太复杂的逻辑。
send()如果转账失败,不会revert。
send()的返回值是bool,代表着转账成功或失败,需要额外代码处理一下。
// send()发送ETH
function sendETH(address payable _to, uint256 amount) external payable{
// 处理下send的返回值,如果失败,revert交易并发送error
bool success = _to.send(amount);
if(!success){
revert SendFailed();
}
}
- call
用法是接收方地址.call{value: 发送ETH数额}(“”)。
call()没有gas限制,可以支持对方合约fallback()或receive()函数实现复杂逻辑。
call()如果转账失败,不会revert。
call()的返回值是(bool, data),其中bool代表着转账成功或失败,需要额外代码处理一下。
// call()发送ETH
function callETH(address payable _to, uint256 amount) external payable{
// 处理下call的返回值,如果失败,revert交易并发送error
(bool success,) = _to.call{value: amount}("");
if(!success){
revert CallFailed();
}
}
call没有gas限制,最为灵活,是最提倡的方法;
transfer有2300 gas限制,但是发送失败会自动revert交易,是次优选择;
send有2300 gas限制,而且发送失败不会自动revert交易,几乎没有人用它。
try catch
在solidity中,try-catch只能被用于external函数或创建合约时constructor(被视为external函数)的调用。基本语法如下:
try externalContract.f() {
// call成功的情况下 运行一些代码
} catch {
// call失败的情况下 运行一些代码
}
其中externalContract.f()是某个外部合约的函数调用,try模块在调用成功的情况下运行,而catch模块则在调用失败时运行。
同样可以使用this.f()来替代externalContract.f(),this.f()也被视作为外部调用,但不可在构造函数中使用,因为此时合约还未创建。
如果调用的函数有返回值,那么必须在try之后声明returns(returnType val),并且在try模块中可以使用返回的变量;如果是创建合约,那么返回值是新创建的合约变量。
代币水龙头
我们实现一个简版的ERC20水龙头,逻辑非常简单:我们将一些ERC20代币转到水龙头合约里,用户可以通过合约的requestToken()函数来领取100单位的代币,每个地址只能领一次。
pragma solidity ^0.8.0;
contract Faucet {
uint256 public amountAllowed = 100; // 每次领 100 单位代币
address public tokenContract; // token合约地址
mapping(address => bool) public requestedAddress; // 记录领取过代币的地址
// SendToken事件
event SendToken(address indexed receiver, uint256 indexed amount);
// 部署时设定ERC20代币合约
constructor(address _tokenContract) {
tokenContract = _tokenContract; // 设置token合约地址
}
// 用户领取代币函数
function requestTokens() external {
require(requestedAddress[msg.sender] == false, "Can't request multiple times!"); // 确保每个地址只能领一次
IERC20 token = IERC20(tokenContract); // 创建IERC20合约对象实例
require(token.balanceOf(address(this)) >= amountAllowed, "Faucet is empty!"); // 检查合约是否有足够的代币
token.transfer(msg.sender, amountAllowed); // 向用户发送token
requestedAddress[msg.sender] = true; // 标记该地址已领取
emit SendToken(msg.sender, amountAllowed); // 触发SendToken事件
}
}
首先,部署ERC20代币合约,名称和符号为WTF,并给自己mint 10000 单位代币。
部署Faucet水龙头合约,初始化的参数填上面ERC20代币的合约地址。
利用ERC20代币合约的transfer()函数,将 10000 单位代币转账到Faucet合约地址。
换一个新账户,调用Faucet合约requestTokens()函数,领取代币。可以在终端看到SendToken事件被释放。
在ERC20代币合约上利用balanceOf查询领取水龙头的账户余额,可以看到余额变为100,领取成功!
ERC721
EIP与ERC
EIP全称 Ethereum Improvement Proposals(以太坊改进建议), 是以太坊开发者社区提出的改进建议, 是一系列以编号排定的文件, 类似互联网上IETF的RFC。
EIP可以是 Ethereum 生态中任意领域的改进, 比如新特性、ERC、协议改进、编程工具等等。
ERC全称 Ethereum Request For Comment (以太坊意见征求稿), 用以记录以太坊上应用级的各种开发标准和协议。如典型的Token标准(ERC20, ERC721)、名字注册(ERC26, ERC13), URI范式(ERC67), Library/Package格式(EIP82), 钱包格式(EIP75,EIP85)。
ERC协议标准是影响以太坊发展的重要因素, 像ERC20, ERC223, ERC721, ERC777等, 都是对以太坊生态产生了很大影响。
所以最终结论:EIP包含ERC。
ERC165
通过ERC165,智能合约可以声明他它支持的接口,供其他合约检查。就是说,实际上检查一个智能合约是不是支持了ERC721,ERC1155的接口。
IERC165接口合约只声明了一个supportInterface函数,输入要查询的interfacedId就扣id,若合约实现了接口id则返回true。
function supportsInterface(bytes4 interfaceId) external pure override returns (bool)
{
return
interfaceId == type(IERC721).interfaceId ||
interfaceId == type(IERC165).interfaceId;
}
IERC721
规定了ERC721要实现的基本函数。它利用tokenId来标识特定的非同质化代币,授权或转账都要明确tokenId;ERC20只需要明确转账的数额即可。
/**
* @dev ERC721标准接口.
*/
interface IERC721 is IERC165 {
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
function balanceOf(address owner) external view returns (uint256 balance);
function ownerOf(uint256 tokenId) external view returns (address owner);
function safeTransferFrom(
address from,
address to,
uint256 tokenId,
bytes calldata data
) external;
function safeTransferFrom(
address from,
address to,
uint256 tokenId
) external;
function transferFrom(
address from,
address to,
uint256 tokenId
) external;
function approve(address to, uint256 tokenId) external;
function setApprovalForAll(address operator, bool _approved) external;
function getApproved(uint256 tokenId) external view returns (address operator);
function isApprovedForAll(address owner, address operator) external view returns (bool);
}
事件
包括三个事件,其中Transfer和Approval在ERC20中也有。
Transfer事件:在转账时被释放,记录代币的发出地址from,接收地址to和tokenid。
Approval事件:在授权时释放,记录授权地址owner,被授权地址approved和tokenid。
ApprovalForAll事件:在批量授权时释放,记录批量授权的发出地址owner,被授权地址operator和授权与否的approved。
函数
balanceOf:返回某地址的NFT持有量balance。
ownerOf:返回某tokenId的主人owner。
transferFrom:普通转账,参数为转出地址from,接收地址to和tokenId。
safeTransferFrom:安全转账(如果接收方是合约地址,会要求实现ERC721Receiver接口)。参数为转出地址from,接收地址to和tokenId。
approve:授权另一个地址使用你的NFT。参数为被授权地址approve和tokenId。
getApproved:查询tokenId被批准给了哪个地址。
setApprovalForAll:将自己持有的该系列NFT批量授权给某个地址operator。
isApprovedForAll:查询某地址的NFT是否批量授权给了另一个operator地址。
safeTransferFrom:安全转账的重载函数,参数里面包含了data。
ERC721Receiver
`