# 前言
过去曾简单接触过 solidity, 合约的内容基本上都是对于函数的调用,感觉和过去看 app 的 java 源码差不多,都是对事件的调用执行。曾经也在 Ropsten 测试网络中进行过几次智能合约的交易,对于交易的过程还是大致有一些了解的,那么我们首先从 solidity 的运行环境开始吧.
# 环境配置
这里提供两种配置 solidity 环境的方法
# 通过 Remix 在网页执行 solidity (推荐,方便快捷)
Solidity 开发环境搭建
# 通过 visual code 安装 solidity
solidity vscode 环境配置
1、安装 vscode、node、git 等;
2、打开 vscode,搜索扩展组建 “solidity + hardhat”,安装;
3、安装 prettier 扩展组建
4、格式化代码设置:
“查看” - “命令面板”,输入 setting,点击 Open setting JSON,
增加:
"[solidity]": { | |
"editor.defaultFormatter": "NomicFoundation.hardhat-solidity" | |
}, | |
"[javascript]":{ | |
"editor.defaultFormatter": "esbenp.prettier-vscode" | |
} | |
"editor.formatOnSave": true |
这样每次保存时,就自动格式化代码;
5、“查看” - “命令面板”,输入 setting,点击 Open User settings,
# solidity 的数值类型
solidity 的数值类型有四类,分别是
- 布尔型
和 c 语言的布尔型类似,solidity 的布尔类型 (bool
) 也有true
和false
两个值
// 布尔值 | |
bool public _bool = true; |
而布尔型的运算符包括
- !(逻辑非)
- && (逻辑与,即 and)
- || (逻辑非,即 or)
- == (等于)
- != (不等于)
注意:
&&
和||
运算符遵循的是短路规则,从左到右顺序开始运算,例如有f(x)||g(y)
, 如果f(x)
的值是true
, 那么g(y)
的值将会不会被运算,f(x)&&g(y)
也是同理,如果f(x)
的值是false
, 那么g(y)
同样不会被计算.(这可以用来节约 gas)
- 整型
// 整型 | |
int public _int = -1; // 整数,包括负数 | |
uint public _uint = 1; // 正整数 | |
uint256 public _number = 20220330; // 256 位正整数 |
常用的整型运算符包括:
- 比较运算符(返回布尔值):
<=
,<
,==
,!=
,>=
,>
- 算数运算符:
+
, ``, 一元运算+
,-
,*
,/
,%
(取余),**
(幂)
这里除了幂运算 ( **
) 和 python 的语法类似,别的整型运算符可以参考 c 语言
- 地址类型
// 地址 | |
address public _address = 0x7A58c0Be72BE218B41C608b7Fe7C5bB630736C71; | |
address payable public _address1 = payable(_address); //payable address,可以转账、查余额 | |
// 地址类型的成员 | |
uint256 public balance = _address1.balance; // balance of address |
此处的 payable
可以重点关注下,因为 payable address
拥有 balance
和 transfer()
两个成员,简单来说, banlance
用来查询账户的余额,而 transfer()
则可以向一个地址发送以太.
- 定长字节数组
字节数组bytes
分两种,一种定长(byte
,bytes8
,bytes32
),另一种不定长。定长的属于数值类型,不定长的是引用类型。 定长bytes
可以存一些数据,消耗gas
比较少。
// 固定长度的字节数组 | |
bytes32 public _byte32 = "MiniSolidity"; | |
bytes1 public _byte = _byte32[0]; |
MiniSolidity
变量以字节的方式存储进变量 _byte32
,转换成 16进制
为: 0x4d696e69536f6c69646974790000000000000000000000000000000000000000
_byte
变量存储 _byte32
的第一个字节,为 0x4d
。
- 枚举类型 enum
// 用 enum 将 uint 0, 1, 2 表示为 Buy, Hold, Sell | |
enum ActionSet { Buy, Hold, Sell } | |
// 创建 enum 变量 action | |
ActionSet action = ActionSet.Buy; |
# solidity 的函数类型
function <function name> (<parameter types>) {internal|external|public|private} [pure|view|payable] [returns (<return types>)] |
-
function
用来声明一个函数,固定用法,这与 python 用def
来声明一个函数有些类似 -
<function name>
函数名,自己定 -
(<parameter types>)
传入函数中的参数 -
{internal|external|public|private}
说明函数的类型,未标明默认函数类型为internal
.public
: 内部外部均可见。(也可用于修饰状态变量,public 变量会自动生成getter
函数,用于查询数值).private
: 只能从本合约内部访问,继承的合约也不能用(也可用于修饰状态变量)external
: 只能从合约外部访问(但是可以用this.f()
来调用,f
是函数名)internal
: 只能从合约内部访问,继承的合约可以用(也可用于修饰状态变量)
-
[pure|view|payable]
决定函数权限 / 功能的关键字。带有payable
的函数可以用来给合约转入 ETH, 而pure
和view
, 根据 wtf 大佬的说法,是因为gas fee
的原因,合约的状态变量存储在链上,gas fee
很贵,如果不改变链上状态,就不用付gas
。包含pure
跟view
关键字的函数是不改写链上状态的,因此用户直接调用他们是不需要付 gas 的(合约中pure
/view
函数调用它们则会改写链上状态,需要付 gas)。在以太坊中,以下语句被视为修改链上状态:
- 写入状态变量。
- 释放事件。
- 创建其他合同。
- 使用
selfdestruct
. - 通过调用发送以太币。
- 调用任何未标记
view
或pure
的函数。 - 使用低级调用(low-level calls)。
- 使用包含某些操作码的内联汇编。
这里总结一下三个关键字的作用
pure
不能读写view
能读不能写payable
可以用来交易
-
[returns ()]
函数返回的变量类型和名称。
# solidity 的函数输出
solidity 支持函数返回多个值,solidity 函数输出的关键字为 return
和 returns
returns
加在函数名后面,用于声明返回的变量类型及变量名;return
用于函数主体中,返回指定的变量。
// 返回多个变量 | |
function returnMultiple() public pure returns(uint256, bool, uint256[3] memory){ | |
return(1, true, [uint256(1),2,5]); | |
} |
当然,solidity 也可以使用命名式返回,示例如下
// 命名式返回 | |
function returnNamed() public pure returns(uint256 _number, bool _bool, uint256[3] memory _array){ | |
_number = 2; | |
_bool = false; | |
_array = [uint256(3),2,1]; | |
//return (1, true, [uint256 (1),2,5]); 也可以直接用 return 返回值 | |
} |
# solidity 变量的数据存储类型
solidity 数据存储位置有三类: storage
, memory
和 calldata
。不同存储位置的 gas
成本不同。 storage
类型的数据存在链上,类似计算机的硬盘,消耗 gas
多; memory
和 calldata
类型的临时存在内存里,消耗 gas
少。大致用法:
storage
:合约里的状态变量默认都是storage
,存储在链上。memory
:函数里的参数和临时变量一般用memory
,存储在内存中,不上链。calldata
:和memory
类似,存储在内存中,不上链。与memory
的不同点在于calldata
变量不能修改(immutable
),一般用于函数的参数。例子:
function fCalldata(uint[] calldata _x) public pure returns(uint[] calldata){ | |
// 参数为 calldata 数组,不能被修改 | |
//_x [0] = 0 // 这样修改会报错 | |
return(_x); | |
} |
# 数据位置和赋值规则
storage
(合约的状态变量)赋值给本地storage
(函数里的)时候,会创建引用,改变新变量会影响原变量。例子:
uint[] x = [1,2,3]; // 状态变量:数组 x | |
function fStorage() public{ | |
// 声明一个 storage 的变量 xStorage,指向 x。修改 xStorage 也会影响 x | |
uint[] storage xStorage = x; | |
xStorage[0] = 100; | |
} |
storage
赋值给memory
,会创建独立的副本,修改其中一个不会影响另一个;反之亦然。例子:
uint[] x = [1,2,3]; // 状态变量:数组 x | |
function fMemory() public view{ | |
// 声明一个 Memory 的变量 xMemory,复制 x。修改 xMemory 不会影响 x | |
uint[] memory xMemory = x; | |
xMemory[0] = 100; | |
xMemory[1] = 200; | |
uint[] memory xMemory2 = x; | |
xMemory2[0] = 300; | |
} |
memory
赋值给memory
,会创建引用,改变新变量会影响原变量。
function fMemory2() public returns(uint8[3] memory){ | |
uint8[3] memory x_tmp=[1,2,3]; | |
uint8[3] memory xMemory = x_tmp; | |
xMemory[0] = 100; | |
xMemory[1] = 200; | |
return x_tmp; | |
} |
- 其他情况,变量赋值给
storage
,会创建独立的复本,修改其中一个不会影响另一个。
function fStorage() public returns(uint){ | |
uint xMemory; | |
xMemory=123456; | |
x[0]=xMemory; | |
xMemory=66666666; | |
return x[0]; | |
} |
# 四种规则的测试代码
//SPDX-License-Identifier:MIT | |
pragma solidity ^0.8.7; | |
contract dataStorageRule{ | |
uint[] x=[1,2,3]; // 类型为 storage | |
function RuleOne() public returns(uint){ | |
x=[1,2,3]; | |
uint[] storage xstorage = x; | |
xstorage[0]=111; | |
return x[0]; | |
} | |
function Ruletwo() public returns(uint,uint,uint){ | |
x=[1,2,3]; | |
uint[] memory xstorage = x; | |
xstorage[0]=111; | |
xstorage[1]=222; | |
xstorage[2]=333; | |
return (x[0],x[1],x[2]); | |
} | |
function Rulethree() public returns(uint8[3] memory){ | |
uint8[3] memory x_tmp=[1,2,3]; | |
uint8[3] memory xMemory = x_tmp; | |
xMemory[0] = 100; | |
xMemory[1] = 200; | |
return x_tmp; | |
} | |
function Rulefour() public returns(uint){ | |
uint xMemory; | |
xMemory=123456; | |
x[0]=xMemory; | |
xMemory=66666666; | |
return x[0]; | |
} | |
} |
- Ruleone
- Ruletwo
- Rulethree
- Rulefour
# 这里用一张关系图来总结一下这四种规则
# 变量的作用域
Solidity
中变量按作用域划分有三种,分别是状态变量(state variable),局部变量(local variable)和全局变量 (global variable)
- 状态变量
指的是存储在链上的变量,所有合约内的函数都可以访问,定义在合约内,函数外,可以在函数内修改状态变量的值,gas 消耗高.
contract Variables { | |
uint public x = 1; | |
uint public y; | |
string public z; |
- 局部变量
存储在内存中,gas 消耗低
function bar() external pure returns(uint){ | |
uint xx = 1; | |
uint yy = 3; | |
uint zz = xx + yy; | |
return(zz); | |
} |
- 全局变量
全局变量是全局范围工作的变量,都是solidity
预留关键字。他们可以在函数内不声明直接使用
function global() external view returns(address, uint, bytes memory){ | |
address sender = msg.sender; | |
uint blockNum = block.number; | |
bytes memory data = msg.data; | |
return(sender, blockNum, data); | |
} |
常用的全局变量关键字:
blockhash(uint blockNumber) returns (bytes32)
:指定区块的区块哈希 —— 仅可用于最新的 256 个区块且不包括当前区块,否则返回 0 。block.basefee
(uint
): 当前区块的基础费用,参考: (EIP-3198 和 EIP-1559)block.chainid
(uint
): 当前链 idblock.coinbase
(address
): 挖出当前区块的矿工地址block.difficulty
(uint
): 当前区块难度block.gaslimit
(uint
): 当前区块 gas 限额block.number
(uint
): 当前区块号block.timestamp
(uint
): 自 unix epoch 起始当前区块以秒计的时间戳gasleft() returns (uint256)
:剩余的 gasmsg.data
(bytes
): 完整的 calldatamsg.sender
(address
): 消息发送者(当前调用)msg.sig
(bytes4
): calldata 的前 4 字节(也就是函数标识符)msg.value
(uint
): 随消息发送的 wei 的数量tx.gasprice
(uint
): 交易的 gas 价格tx.origin
(address
): 交易发起者(完全的调用链)
# solidity 的引用类型
引用类型 (Reference Type):包括数组( array
),结构体( struct
)和映射( mapping
),这类变量占空间大,赋值时候直接传递地址(类似指针)。由于这类变量比较复杂,占用存储空间大,我们在使用时必须要声明数据存储的位置。
# 数组 array
数组( Array
)是 solidity
常用的一种变量类型,用来存储一组数据(整数,字节,地址等等)。数组分为固定长度数组和可变长度数组两种:
- 固定长度数组:在声明时指定数组的长度。用
T[k]
的格式声明,其中T
是元素的类型,k
是长度,例如:
// 固定长度 Array | |
uint[8] array1; | |
bytes1[5] array2; | |
address[100] array3; |
・可变长度数组(动态数组):在声明时不指定数组的长度。用 T[]
的格式声明,其中 T
是元素的类型,例如( **bytes
比较特殊,是数组,但是不用加 []
**):
// 可变长度 Array | |
uint[] array4; | |
bytes1[] array5; | |
address[] array6; | |
bytes array7; |
# 创建数组的规则
- 对于
memory
修饰的动态数组
,可以用new
操作符来创建,但是必须声明长度,并且声明后长度不能改变。例子:
//memory 动态数组 | |
uint[] memory array8 = new uint[](5); | |
bytes memory array9 = new bytes(9); |
- 数组字面常数 (Array Literals) 是写作表达式形式的数组,用方括号包着来初始化 array 的一种方式,并且里面每一个元素的 type 是以第一个元素为准的,例如
[1,2,3]
里面所有的元素都是 uint8 类型,因为在 solidity 中如果一个值没有指定 type 的话,默认就是最小单位的该 type,这里 int 的默认最小单位类型就是 uint8。而[uint(1),2,3]
里面的元素都是 uint 类型,因为第一个元素指定了是 uint 类型了,我们都以第一个元素为准。 - 如果创建的是动态数组,你需要一个一个元素的赋值。
uint[] memory x = new uint[](3); | |
x[0] = 1; | |
x[1] = 3; | |
x[2] = 4; |
# 数组成员
length
: 数组有一个包含元素数量的length
成员,memory
数组的长度在创建后是固定的。push()
:动态数组
和bytes
拥有push()
成员,可以在数组最后添加一个0
元素。push(x)
:动态数组
和bytes
拥有push(x)
成员,可以在数组最后添加一个x
元素。pop()
:动态数组
和bytes
拥有pop()
成员,可以移除数组最后一个元素。
# 结构体 struct
创建一个结构体:
// 结构体 | |
struct Student{ | |
uint256 id; | |
uint256 score; | |
} |
初始化一个结构体:
Student student; // 初始一个 student 结构体 |
结构体赋值的两种方法:
- 方法 1: 在函数中创建一个
storage
的struct
引用
function initStudent1() external{ | |
Student storage _student = student; // assign a copy of student | |
_student.id = 11; | |
_student.score = 100; | |
} |
- 方法 2: 直接引用状态变量的
struct
function initStudent2() external{ | |
student.id = 1; | |
student.score = 80; | |
} |
# solidity 中的哈希表:映射类型 (mapping)
# 映射 mapping
在映射中,人们可以通过键( Key
)来查询对应的值( Value
),比如:通过一个人的 id
来查询他的钱包地址。
声明映射的格式为 mapping(_KeyType => _ValueType)
,其中 _KeyType
和 _ValueType
分别是 Key
和 Value
的变量类型。例子:
mapping(uint => address) public idToAddress; //id 映射到地址 | |
mapping(address => address) public swapPair; // 币对的映射,地址到地址 |
这里用 mapping(uint => address) public idToAddress
来说明一下映射类型,例如有一个用户他的 id 叫做 oacia
, 并且他的钱包地址是 0xABCDEF
, 那么我们可以使用如下语句 idToAddress[oacia]=0xABCDEF
这种方式,来将用户 oacia
与他的钱包地址 0xABCDEF
联系起来,下次用户 oacia
再次使用这一个合约时,通过映射 idToAddress
可以马上知道这个用户的钱包地址为 0xABCDEF
.
# 映射的规则
- 规则 1:映射的
_KeyType
只能选择solidity
默认的类型,比如uint
,address
等,不能用自定义的结构体。而_ValueType
可以使用自定义的类型。下面这个例子会报错,因为_KeyType
使用了我们自定义的结构体:
// 我们定义一个结构体 Struct | |
struct Student{ | |
uint256 id; | |
uint256 score; | |
} | |
mapping(Student => uint) public testVar; |
- 规则 2:映射的存储位置必须是
storage
,因此可以用于合约的状态变量,函数中的storage
变量,和 library 函数的参数(见例子)。不能用于public
函数的参数或返回结果中,因为mapping
记录的是一种关系 (key - value pair)。 - 规则 3:如果映射声明为
public
,那么solidity
会自动给你创建一个getter
函数,可以通过Key
来查询对应的Value
。 - 规则 4:给映射新增的键值对的语法为
_Var[_Key] = _Value
,其中_Var
是映射变量名,_Key
和_Value
对应新增的键值对。例子:
function writeMap (uint _Key, address _Value) public{ | |
idToAddress[_Key] = _Value; | |
} |
# 映射的原理
- 原理 1: 映射不储存任何键(
Key
)的资讯,也没有 length 的资讯。 - 原理 2: 映射使用
keccak256(key)
当成 offset 存取 value。 - 原理 3: 因为 Ethereum 会定义所有未使用的空间为 0,所以未赋值(
Value
)的键(Key
)初始值都是 0。
# 变量的初始值
当我们刚定义一个变量并且没有给变量赋值时,solidity 会默认为新定义的变量赋初值,具体如下
# 值类型初始值
boolean
:false
string
:""
int
:0
uint
:0
enum
: 枚举中的第一个元素address
:0x0000000000000000000000000000000000000000
(或address(0)
)function
internal
: 空白方程external
: 空白方程
# 引用类型初始值
- 映射
mapping
: 所有元素都为其默认值的mapping
- 结构体
struct
: 所有成员设为其默认值的结构体 - 数组
array
- 动态数组:
[]
- 静态数组(定长): 所有成员设为其默认值的静态数组
- 动态数组:
# delete 操作符
delete a
会让变量 a
的值变为初始值。
//delete 操作符 | |
bool public _bool2 = true; | |
function d() external { | |
delete _bool2; //delete 会让_bool2 变为默认值,false | |
} |
# solidity 的常数
solidity
中两个关键字, constant
(常量)和 immutable
(不变量)都可以用来表示常数,状态变量声明这个两个关键字之后,不能在合约后更改数值;并且还可以节省 gas
. 另外,只有数值变量可以声明 constant
和 immutable
; string
和 bytes
可以声明为 constant
,但不能为 immutable
。
# constant
constant
变量必须在声明的时候初始化,之后再也不能改变。尝试改变的话,编译不通过。
//constant 变量必须在声明的时候初始化,之后不能改变 | |
uint256 constant CONSTANT_NUM = 10; | |
string constant CONSTANT_STRING = "0xAA"; | |
bytes constant CONSTANT_BYTES = "WTF"; | |
address constant CONSTANT_ADDRESS = 0x0000000000000000000000000000000000000000; |
# immutable
immutable
变量可以在声明时或构造函数中初始化,因此更加灵活。
//immutable 变量可以在 constructor 里初始化,之后不能改变 | |
uint256 public immutable IMMUTABLE_NUM = 9999999999; | |
address public immutable IMMUTABLE_ADDRESS; | |
uint256 public immutable IMMUTABLE_BLOCK; | |
uint256 public immutable IMMUTABLE_TEST; |
你可以使用全局变量例如 address(this)
, block.number
,或者自定义的函数给 immutable
变量初始化。在下面这个例子,我们利用了 test()
函数给 IMMUTABLE_TEST
初始化为 9
:
// 利用 constructor 初始化 immutable 变量,因此可以利用 | |
constructor(){ | |
IMMUTABLE_ADDRESS = address(this); | |
IMMUTABLE_BLOCK = block.number; | |
IMMUTABLE_TEST = test(); | |
} | |
function test() public pure returns(uint256){ | |
uint256 what = 9; | |
return(what); | |
} |
# solidity 的控制流
简单看了看,完全可以将 solidity 的控制流和 c 语言类比,因为逻辑基本上是一样的
if-else
function ifElseTest(uint256 _number) public pure returns(bool){ | |
if(_number == 0){ | |
return(true); | |
}else{ | |
return(false); | |
} | |
} |
for循环
function forLoopTest() public pure returns(uint256){ | |
uint sum = 0; | |
for(uint i = 0; i < 10; i++){ | |
sum += i; | |
} | |
return(sum); | |
} |
while循环
function whileTest() public pure returns(uint256){ | |
uint sum = 0; | |
uint i = 0; | |
while(i < 10){ | |
sum += i; | |
i++; | |
} | |
return(sum); | |
} |
do-while循环
function doWhileTest() public pure returns(uint256){ | |
uint sum = 0; | |
uint i = 0; | |
do{ | |
sum += i; | |
i++; | |
}while(i < 10); | |
return(sum); | |
} |
三元运算符
三元运算符是solidity
中唯一一个接受三个操作数的运算符,规则条件? 条件为真的表达式:条件为假的表达式
。 此运算符经常用作 if 语句的快捷方式。
// 三元运算符 ternary/conditional operator | |
function ternaryTest(uint256 x, uint256 y) public pure returns(uint256){ | |
// return the max of x and y | |
return x >= y ? x: y; | |
} |
另外还有 continue
(立即进入下一个循环)和 break
(跳出当前循环)关键字可以使用。
# solidity 的构造函数与修饰器
# 构造函数
构造函数( constructor
)是一种特殊的函数,每个合约可以定义一个,并在部署合约的时候自动运行一次。它可以用来初始化合约的一些参数,例如初始化合约的 owner
地址:
address owner; // 定义 owner 变量 | |
// 构造函数 | |
constructor() { | |
owner = msg.sender; // 在部署合约的时候,将 owner 设置为部署者的地址 | |
} |
# 修饰器
修饰器( modifier
)是 solidity
特有的语法,类似于面向对象编程中的 decorator
,声明函数拥有的特性,并减少代码冗余。它就像钢铁侠的智能盔甲,穿上它的函数会带有某些特定的行为。 modifier
的主要使用场景是运行函数前的检查,例如地址,变量,余额等。
我们来定义一个叫做 onlyOwner 的 modifier:
// 定义 modifier | |
modifier onlyOwner { | |
require(msg.sender == owner);// 检查调用者是否为 owner 地址 | |
_;// 如果是的话,继续运行函数主体;否则报错并 revert 交易 | |
} |
代有 onlyOwner
修饰符的函数只能被 owner
地址调用,比如下面这个例子:
function changeOwner(address _newOwner) external onlyOwner{ | |
owner = _newOwner;// 只有 owner 地址运行这个函数,并改变 owner | |
} |
我们定义了一个 changeOwner
函数,运行他可以改变合约的 owner
,但是由于 onlyOwner
修饰符的存在,只有原先的 owner
可以调用,别人调用就会报错。这也是最常用的控制智能合约权限的方法。
# solidity 的事件
事件是使用 EVM 日志内置功能的方便工具,在 dapp 的接口中,它可以反过来调用 Javascript 的监听
事件的回调。
Solidity
中的事件( event
)是 EVM
上日志的抽象,它具有两个特点:
- 响应:应用程序(
[ether.js](https://learnblockchain.cn/docs/ethers.js/api-contract.html#id18)
)可以通过RPC
接口订阅和监听这些事件,并在前端做响应。 - 经济:事件是
EVM
上比较经济的存储数据的方式,每个大概消耗 2,000gas
;相比之下,链上存储一个新变量至少需要 20,000gas
。
# 规则
事件的声明由 event
关键字开头,然后跟事件名称,括号里面写好事件需要记录的变量类型和变量名。以 ERC20
代币合约的 Transfer
事件为例:
event Transfer(address indexed from, address indexed to, uint256 value); |
我们可以看到, Transfer
事件共记录了 3 个变量 from
, to
和 value
,分别对应代币的转账地址,接收地址和转账数量。
同时 from
和 to
前面带着 indexed
关键字,每个 indexed
标记的变量可以理解为检索事件的索引 “键”,在以太坊上单独作为一个 topic
进行存储和索引,程序可以轻松的筛选出特定转账地址和接收地址的转账事件。每个事件最多有 3 个带 indexed
的变量。每个 indexed
变量的大小为固定的 256 比特。事件的哈希以及这三个带 indexed
的变量在 EVM
日志中通常被存储为 topic
。其中 topic[0]
是此事件的 keccak256
哈希, topic[1]
到 topic[3]
存储了带 indexed
变量的 keccak256
哈希。
value
不带 indexed
关键字,会存储在事件的 data
部分中,可以理解为事件的 “值”。 data
部分的变量不能被直接检索,但可以存储任意大小的数据。因此一般 data
部分可以用来存储复杂的数据结构,例如数组和字符串等等,因为这些数据超过了 256 比特,即使存储在事件的 topic
部分中,也是以哈希的方式存储。另外, data
部分的变量在存储上消耗的 gas 相比于 topic
更少。
我们可以在函数里释放事件。在下面的例子中,每次用 _transfer()
函数进行转账操作的时候,都会释放 Transfer
事件,并记录相应的变量。
// 定义_transfer 函数,执行转账逻辑 | |
function _transfer( | |
address from, | |
address to, | |
uint256 amount | |
) external { | |
_balances[from] = 10000000; // 给转账地址一些初始代币 | |
_balances[from] -= amount; //from 地址减去转账数量 | |
_balances[to] += amount; //to 地址加上转账数量 | |
// 释放事件 | |
emit Transfer(from, to, amount); | |
} |
# solidity 中的继承
# 继承
继承是面向对象编程很重要的组成部分,可以显著减少重复代码。如果把合约看作是对象的话, solidity
也是面向对象的编程,也支持继承。
# 规则
virtual
: 父合约中的函数,如果希望子合约重写,需要加上virtual
关键字。override
:子合约重写了父合约中的函数,需要加上override
关键字。
# 简单继承
我们先写一个简单的爷爷合约 Yeye
,里面包含 1 个 Log
事件和 3 个 function
: hip()
, pop()
, yeye()
,输出都是”Yeye”。
contract Yeye { | |
event Log(string msg); | |
// 定义 3 个 function: hip (), pop (), man (),Log 值为 Yeye。 | |
function hip() public virtual{ | |
emit Log("Yeye"); | |
} | |
function pop() public virtual{ | |
emit Log("Yeye"); | |
} | |
function yeye() public virtual { | |
emit Log("Yeye"); | |
} | |
} |
我们再定义一个爸爸合约 Baba
,让他继承 Yeye
合约,语法就是 contract Baba is Yeye
,非常直观。在 Baba
合约里,我们重写一下 hip()
和 pop()
这两个函数,加上 override
关键字,并将他们的输出改为 ”Baba”
;并且加一个新的函数 baba
,输出也是 ”Baba”
。
contract Baba is Yeye{ | |
// 继承两个 function: hip () 和 pop (),输出改为 Baba。 | |
function hip() public virtual override{ | |
emit Log("Baba"); | |
} | |
function pop() public virtual override{ | |
emit Log("Baba"); | |
} | |
function baba() public virtual{ | |
emit Log("Baba"); | |
} | |
} |
我们部署合约,可以看到 Baba
合约里有 4 个函数,其中 hip()
和 pop()
的输出被成功改写成 ”Baba”
,而继承来的 yeye()
的输出仍然是 ”Yeye”
。
# 多重继承
solidity
的合约可以继承多个合约。规则:
继承时要按辈分最高到最低的顺序排。比如我们写一个 Erzi
合约,继承 Yeye
合约和 Baba
合约,那么就要写成 contract Erzi is Yeye, Baba
,而不能写成 contract Erzi is Baba, Yeye
,不然就会报错。 如果某一个函数在多个继承的合约里都存在,比如例子中的 hip()
和 pop()
,在子合约里必须重写,不然会报错。 重写在多个父合约中都重名的函数时, override
关键字后面要加上所有父合约名字,例如 override(Yeye, Baba)
。 例子:
contract Erzi is Yeye, Baba{ | |
// 继承两个 function: hip () 和 pop (),输出值为 Erzi。 | |
function hip() public virtual override(Yeye, Baba){ | |
emit Log("Erzi"); | |
} | |
function pop() public virtual override(Yeye, Baba) { | |
emit Log("Erzi"); | |
} |
我们可以看到, Erzi
合约里面重写了 hip()
和 pop()
两个函数,将输出改为 ”Erzi”
,并且还分别从 Yeye
和 Baba
合约继承了 yeye()
和 baba()
两个函数。
# 修饰器的继承
Solidity
中的修饰器( Modifier
)同样可以继承,用法与函数继承类似,在相应的地方加 virtual
和 override
关键字即可。
contract Base1 { | |
modifier exactDividedBy2And3(uint _a) virtual { | |
require(_a % 2 == 0 && _a % 3 == 0); | |
_; | |
} | |
} | |
contract Identifier is Base1 { | |
// 计算一个数分别被 2 除和被 3 除的值,但是传入的参数必须是 2 和 3 的倍数 | |
function getExactDividedBy2And3(uint _dividend) public exactDividedBy2And3(_dividend) pure returns(uint, uint) { | |
return getExactDividedBy2And3WithoutModifier(_dividend); | |
} | |
// 计算一个数分别被 2 除和被 3 除的值 | |
function getExactDividedBy2And3WithoutModifier(uint _dividend) public pure returns(uint, uint){ | |
uint div2 = _dividend / 2; | |
uint div3 = _dividend / 3; | |
return (div2, div3); | |
} | |
} |
Identifier
合约可以直接在代码中使用父合约中的 exactDividedBy2And3
修饰器,也可以利用 override
关键字重写修饰器:
modifier exactDividedBy2And3(uint _a) override { | |
_; | |
require(_a % 2 == 0 && _a % 3 == 0); | |
} |
# 构造函数的继承
子合约有两种方法继承父合约的构造函数。举个简单的例子,父合约 A
里面有一个状态变量 a
,并由构造函数的参数来确定:
// 构造函数的继承 | |
abstract contract A { | |
uint public a; | |
constructor(uint _a) { | |
a = _a; | |
} | |
} |
- 在继承时声明父构造函数的参数,例如:
contract B is A(1)
- 在子合约的构造函数中声明构造函数的参数,例如:
contract C is A { | |
constructor(uint _c) A(_c * _c) {} | |
} |
# 调用父合约的函数
子合约有两种方式调用父合约的函数,直接调用和利用 super
关键字。
- 直接调用:子合约可以直接用
父合约名.函数名()
的方式来调用父合约函数,例如Yeye.pop()
。
function callParent() public{ | |
Yeye.pop(); | |
} |
super
关键字:子合约可以利用super.函数名()
来调用最近的父合约函数。solidity
继承关系按声明时从右到左的顺序是:contract Erzi is Yeye, Baba
,那么Baba
是最近的父合约,super.pop()
将调用Baba.pop()
而不是Yeye.pop()
:
function callParentSuper() public{ | |
// 将调用最近的父合约函数,Baba.pop () | |
super.pop(); | |
} |
# solidity 中的异常
solidity
三种抛出异常的方法: error
, require
和 assert
# 条件检查
# Error
error
是 solidity 0.8版本
新加的内容,方便且高效(省 gas
)地向用户解释操作失败的原因。人们可以在 contract
之外定义异常。下面,我们定义一个 TransferNotOwner
异常,当用户不是代币 owner
的时候尝试转账,会抛出错误:
// Errors 用来定义失败 | |
// 以下称为 natspec 注释,可以通过三个斜杠来识别。 | |
// 当用户被要求确认交易时或错误发生时将显示。 | |
/// you are not the owner | |
error TransferNotOwner();// 自定义 error |
在执行当中, error
必须搭配 revert
(回退)命令使用。
function transferOwner1(uint256 tokenId, address newOwner) public { | |
if(_owners[tokenId] != msg.sender){ | |
revert TransferNotOwner(); | |
} | |
_owners[tokenId] = newOwner; | |
} |
我们定义了一个 transferOwner1()
函数,它会检查代币的 owner
是不是发起人,如果不是,就会抛出 TransferNotOwner
异常;如果是的话,就会转账。
# Require
require
命令是 solidity 0.8版本
之前抛出异常的常用方法,目前很多主流合约仍然还在使用它。它很好用,唯一的缺点就是 gas
随着描述异常的字符串长度增加,比 error
命令要高。使用方法: require(检查条件,"异常的描述")
,当检查条件不成立的时候,就会抛出异常。
我们用 require
命令重写一下上面的 transferOwner
函数:
function transferOwner2(uint256 tokenId, address newOwner) public { | |
require(_owners[tokenId] == msg.sender, "Transfer Not Owner"); | |
_owners[tokenId] = newOwner; | |
} |
# Assert
assert
命令一般用于程序员写程序 debug
,因为它不能解释抛出异常的原因(比 require
少个字符串)。它的用法很简单, assert(检查条件)
,当检查条件不成立的时候,就会抛出异常。
我们用 assert
命令重写一下上面的 transferOwner
函数:
function transferOwner3(uint256 tokenId, address newOwner) public { | |
assert(_owners[tokenId] == msg.sender); | |
_owners[tokenId] = newOwner; | |
} |
注:同样作为判断一个条件是否满足的函数,require 会退回剩下的 gas,而 assert 会消耗所有的 gas。
# 触发异常
提供了 revert
, throw
来触发异常:
throw
:关键字抛出异常(从 0.4.13 版本,throw 关键字已被弃用,将来会被淘汰。)回滚所有状态改变,返回” 无效操作代码错误”,而且消耗掉剩下的 gasrevert
:函数可以用来标记错误并回退当前调用,允许返回一个数值,将剩余 gas 返还调用者
传统处理异常的方式 if...throw
模式
即
等价于:
if(msg.sender != owner) { throw; } |
- if(msg.sender != owner) { revert(); }// 如果不等则异常 | |
- assert(msg.sender == owner);// 校验是否等于 | |
- require(msg.sender == owner); |
# 运行第一个 solidity 代码,hello world
这里我选择在网页运行运行 solidity
//SPDX-License-Identifier:GPL-3.0 | |
pragma solidity ^0.8.7; | |
contract HelloWorldContract{ | |
function helloWorld()external pure returns(string memory){ | |
return "hello world"; | |
} | |
} |
# 阅读一些智能合约
# example1.CSAWDonation
/** | |
*Submitted for verification at Etherscan.io on 2022-09-09 | |
*/ | |
// SPDX-License-Identifier: MIT | |
pragma solidity >=0.8.7; | |
contract CSAWDonation { | |
mapping(address => uint256) public balances; | |
mapping(address => bool) public doneDonating; | |
event sendToAuthor(bytes32 token); | |
function newAccount() public payable{ | |
require(msg.value >= 0.0001 ether); | |
balances[msg.sender] = 10; | |
doneDonating[msg.sender] = false; | |
} | |
function donateOnce() public { | |
require(balances[msg.sender] >= 1); | |
if(doneDonating[msg.sender] == false) { | |
balances[msg.sender] += 10; | |
msg.sender.call{value: 0.0001 ether}(""); | |
doneDonating[msg.sender] = true; | |
} | |
} | |
function getBalance() public view returns (uint256 donatorBalance) { | |
return balances[msg.sender]; | |
} | |
function getFlag(bytes32 _token) public { | |
require(balances[msg.sender] >= 30); | |
emit sendToAuthor(_token); //sends the token | |
} | |
} |
这个合约是有重入漏洞的,至于什么是重入漏洞,可以参考这篇文章
智能合约安全–重入漏洞
抛开这点来说,这个合约对于像我这种 solidity 初学者来说,还是很有参考意义的.
# example2. 插入排序
这里有一个错误的插入排序代码,运行后就会报错
// 插入排序 错误版 | |
function insertionSortWrong(uint[] memory a) public pure returns(uint[] memory) { | |
for (uint i = 1;i < a.length;i++){ | |
uint temp = a[i]; | |
uint j=i-1; | |
while( (j >= 0) && (temp < a[j])){ | |
a[j+1] = a[j]; | |
j--; | |
} | |
a[j+1] = temp; | |
} | |
return(a); | |
} |
为什么呢?因为变量 j
的类型是 uint
, 是不能取到 **-1 的,但是在上述代码中, j
可能会取到 - 1**, 所以要规避掉这种错误,让 j
取不到 **-1**.
// 插入排序 正确版 | |
function insertionSort(uint[] memory a) public pure returns(uint[] memory) { | |
// note that uint can not take negative value | |
for (uint i = 1;i < a.length;i++){ | |
uint temp = a[i]; | |
uint j=i; | |
while( (j >= 1) && (temp < a[j-1])){ | |
a[j] = a[j-1]; | |
j--; | |
} | |
a[j] = temp; | |
} | |
return(a); | |
} |
# example3. 投票合约
// SPDX-License-Identifier: GPL-3.0 | |
pragma solidity >=0.7.0 <0.9.0; | |
/// @title 委托投票 | |
contract Ballot { | |
// 这里声明了一个新的复合类型用于稍后的变量 | |
// 它用来表示一个选民 | |
struct Voter { | |
uint weight; // 计票的权重 | |
bool voted; // 若为真,代表该人已投票 | |
address delegate; // 被委托人 | |
uint vote; // 投票提案的索引 | |
} | |
// 提案的类型 | |
struct Proposal { | |
bytes32 name; // 简称(最长 32 个字节) | |
uint voteCount; // 得票数 | |
} | |
address public chairperson; | |
// 这声明了一个状态变量,为每个可能的地址存储一个 `Voter`。 | |
mapping(address => Voter) public voters; | |
// 一个 `Proposal` 结构类型的动态数组 | |
Proposal[] public proposals; | |
/// 为 `proposalNames` 中的每个提案,创建一个新的(投票)表决 | |
constructor(bytes32[] memory proposalNames) { | |
chairperson = msg.sender; | |
voters[chairperson].weight = 1; | |
// 对于提供的每个提案名称, | |
// 创建一个新的 Proposal 对象并把它添加到数组的末尾。 | |
for (uint i = 0; i < proposalNames.length; i++) { | |
// `Proposal ({...})` 创建一个临时 Proposal 对象, | |
// `proposals.push (...)` 将其添加到 `proposals` 的末尾 | |
proposals.push(Proposal({ | |
name: proposalNames[i], | |
voteCount: 0 | |
})); | |
} | |
} | |
// 授权 `voter` 对这个(投票)表决进行投票 | |
// 只有 `chairperson` 可以调用该函数。 | |
function giveRightToVote(address voter) external { | |
// 若 `require` 的第一个参数的计算结果为 `false`, | |
// 则终止执行,撤销所有对状态和以太币余额的改动。 | |
// 在旧版的 EVM 中这曾经会消耗所有 gas,但现在不会了。 | |
// 使用 require 来检查函数是否被正确地调用,是一个好习惯。 | |
// 你也可以在 require 的第二个参数中提供一个对错误情况的解释。 | |
require( | |
msg.sender == chairperson, | |
"Only chairperson can give right to vote." | |
); | |
require( | |
!voters[voter].voted, | |
"The voter already voted." | |
); | |
require(voters[voter].weight == 0); | |
voters[voter].weight = 1; | |
} | |
/// 把你的投票委托到投票者 `to`。 | |
function delegate(address to) external { | |
// 传引用 | |
Voter storage sender = voters[msg.sender]; | |
require(sender.weight != 0, "You have no right to vote"); | |
require(!sender.voted, "You already voted."); | |
require(to != msg.sender, "Self-delegation is disallowed."); | |
// 委托是可以传递的,只要被委托者 `to` 也设置了委托。 | |
// 一般来说,这种循环委托是危险的。因为,如果传递的链条太长, | |
// 则可能需消耗的 gas 要多于区块中剩余的(大于区块设置的 gasLimit), | |
// 这种情况下,委托不会被执行。 | |
// 而在另一些情况下,如果形成闭环,则会让合约完全卡住。 | |
while (voters[to].delegate != address(0)) { | |
to = voters[to].delegate; | |
// 不允许闭环委托 | |
require(to != msg.sender, "Found loop in delegation."); | |
} | |
// `sender` 是一个引用,相当于对 `voters [msg.sender].voted` 进行修改 | |
Voter storage delegate_ = voters[to]; | |
// Voters cannot delegate to accounts that cannot vote. | |
require(delegate_.weight >= 1); | |
// Since `sender` is a reference, this | |
// modifies `voters[msg.sender]`. | |
sender.voted = true; | |
sender.delegate = to; | |
if (delegate_.voted) { | |
// 若被委托者已经投过票了,直接增加得票数 | |
proposals[delegate_.vote].voteCount += sender.weight; | |
} else { | |
// 若被委托者还没投票,增加委托者的权重 | |
delegate_.weight += sender.weight; | |
} | |
} | |
/// 把你的票 (包括委托给你的票), | |
/// 投给提案 `proposals [proposal].name`. | |
function vote(uint proposal) external { | |
Voter storage sender = voters[msg.sender]; | |
require(!sender.voted, "Already voted."); | |
sender.voted = true; | |
sender.vote = proposal; | |
// 如果 `proposal` 超过了数组的范围,则会自动抛出异常,并恢复所有的改动 | |
proposals[proposal].voteCount += sender.weight; | |
} | |
/// @dev 结合之前所有的投票,计算出最终胜出的提案 | |
function winningProposal() external view | |
returns (uint winningProposal_) | |
{ | |
uint winningVoteCount = 0; | |
for (uint p = 0; p < proposals.length; p++) { | |
if (proposals[p].voteCount > winningVoteCount) { | |
winningVoteCount = proposals[p].voteCount; | |
winningProposal_ = p; | |
} | |
} | |
} | |
// 调用 winningProposal () 函数以获取提案数组中获胜者的索引,并以此返回获胜者的名称 | |
function winnerName() public view | |
returns (bytes32 winnerName_) | |
{ | |
winnerName_ = proposals[winningProposal()].name; | |
} | |
} |
# example4. 简单的公开拍卖
// SPDX-License-Identifier: GPL-3.0 | |
pragma solidity ^0.8.4; | |
contract SimpleAuction { | |
// 拍卖的参数。 | |
address payable public beneficiary; | |
// 时间是 unix 的绝对时间戳(自 1970-01-01 以来的秒数) | |
// 或以秒为单位的时间段。 | |
uint public auctionEnd; | |
// 拍卖的当前状态 | |
address public highestBidder; | |
uint public highestBid; | |
// 可以取回的之前的出价 | |
mapping(address => uint) pendingReturns; | |
// 拍卖结束后设为 true,将禁止所有的变更 | |
bool ended; | |
// 变更触发的事件 | |
event HighestBidIncreased(address bidder, uint amount); | |
event AuctionEnded(address winner, uint amount); | |
// Errors 用来定义失败 | |
// 以下称为 natspec 注释,可以通过三个斜杠来识别。 | |
// 当用户被要求确认交易时或错误发生时将显示。 | |
/// The auction has already ended. | |
error AuctionAlreadyEnded(); | |
/// There is already a higher or equal bid. | |
error BidNotHighEnough(uint highestBid); | |
/// The auction has not ended yet. | |
error AuctionNotYetEnded(); | |
/// The function auctionEnd has already been called. | |
error AuctionEndAlreadyCalled(); | |
/// 以受益者地址 `beneficiaryAddress` 的名义, | |
/// 创建一个简单的拍卖,拍卖时间为 `biddingTime` 秒。 | |
constructor( | |
uint biddingTime, | |
address payable beneficiaryAddress | |
) { | |
beneficiary = beneficiaryAddress; | |
auctionEnd = block.timestamp + biddingTime; | |
} | |
/// 对拍卖进行出价,具体的出价随交易一起发送。 | |
/// 如果没有在拍卖中胜出,则返还出价。 | |
function bid() external payable { | |
// 参数不是必要的。因为所有的信息已经包含在了交易中。 | |
// 对于能接收以太币的函数,关键字 payable 是必须的。 | |
// 如果拍卖已结束,撤销函数的调用。 | |
if (block.timestamp > auctionEndTime) | |
revert AuctionAlreadyEnded(); | |
// 如果出价不够高,返还你的钱 | |
if (msg.value <= highestBid) | |
revert BidNotHighEnough(highestBid); | |
if (highestBid != 0) { | |
// 返还出价时,简单地直接调用 highestBidder.send (highestBid) 函数, | |
// 是有安全风险的,因为它有可能执行一个非信任合约。 | |
// 更为安全的做法是让接收方自己提取金钱。 | |
pendingReturns[highestBidder] += highestBid; | |
} | |
highestBidder = msg.sender; | |
highestBid = msg.value; | |
emit HighestBidIncreased(msg.sender, msg.value); | |
} | |
/// 取回出价(当该出价已被超越) | |
function withdraw() external returns (bool) { | |
uint amount = pendingReturns[msg.sender]; | |
if (amount > 0) { | |
// 这里很重要,首先要设零值。 | |
// 因为,作为接收调用的一部分, | |
// 接收者可以在 `send` 返回之前,重新调用该函数。 | |
pendingReturns[msg.sender] = 0; | |
// msg.sender is not of type `address payable` and must be | |
// explicitly converted using `payable(msg.sender)` in order | |
// use the member function `send()`. | |
if (!payable(msg.sender).send(amount)) { | |
// 这里不需抛出异常,只需重置未付款 | |
pendingReturns[msg.sender] = amount; | |
return false; | |
} | |
} | |
return true; | |
} | |
/// 结束拍卖,并把最高的出价发送给受益人 | |
function auctionEnd() external { | |
// 对于可与其他合约交互的函数(意味着它会调用其他函数或发送以太币), | |
// 一个好的指导方针是将其结构分为三个阶段: | |
// 1. 检查条件 | |
// 2. 执行动作 (可能会改变条件) | |
// 3. 与其他合约交互 | |
// 如果这些阶段相混合,其他的合约可能会回调当前合约并修改状态, | |
// 或者导致某些效果(比如支付以太币)多次生效。 | |
// 如果合约内调用的函数包含了与外部合约的交互, | |
// 则它也会被认为是与外部合约有交互的。 | |
// 1. 条件 | |
if (block.timestamp < auctionEndTime) | |
revert AuctionNotYetEnded(); | |
if (ended) | |
revert AuctionEndAlreadyCalled(); | |
// 2. 生效 | |
ended = true; | |
emit AuctionEnded(highestBidder, highestBid); | |
// 3. 交互 | |
beneficiary.transfer(highestBid); | |
} | |
} |
# 参考资料
- Solidity 中文文档 - Solidity 中文文档 - 登链社区