# 前言

过去曾简单接触过 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,

chrome_y0mIwkr1Dr.png

增加:

"[solidity]": {
        "editor.defaultFormatter": "NomicFoundation.hardhat-solidity"
    },
    "[javascript]":{
      "editor.defaultFormatter": "esbenp.prettier-vscode"
    }
    "editor.formatOnSave": true

这样每次保存时,就自动格式化代码;

5、“查看” - “命令面板”,输入 setting,点击 Open User settings,

chrome_exrP7nVtG6.png

# solidity 的数值类型

solidity 的数值类型有四类,分别是

  1. 布尔型
    和 c 语言的布尔型类似,solidity 的布尔类型 ( bool ) 也有 truefalse 两个值
// 布尔值
    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)

  1. 整型
// 整型
    int public _int = -1; // 整数,包括负数
    uint public _uint = 1; // 正整数
    uint256 public _number = 20220330; // 256 位正整数

常用的整型运算符包括:

  • 比较运算符(返回布尔值):  <= ,  < ,  == ,  != ,  >= ,  >
  • 算数运算符:  + , ``, 一元运算  + , - , */ ,  % (取余), ** (幂)

这里除了幂运算 ( ** ) 和 python 的语法类似,别的整型运算符可以参考 c 语言

  1. 地址类型
// 地址
    address public _address = 0x7A58c0Be72BE218B41C608b7Fe7C5bB630736C71;
    address payable public _address1 = payable(_address); //payable address,可以转账、查余额
    // 地址类型的成员
    uint256 public balance = _address1.balance; // balance of address

此处的 payable 可以重点关注下,因为 payable address 拥有 balancetransfer() 两个成员,简单来说, banlance 用来查询账户的余额,而 transfer() 则可以向一个地址发送以太.

  1. 定长字节数组
    字节数组 bytes 分两种,一种定长( bytebytes8bytes32 ),另一种不定长。定长的属于数值类型,不定长的是引用类型。 定长 bytes 可以存一些数据,消耗 gas 比较少。
// 固定长度的字节数组
    bytes32 public _byte32 = "MiniSolidity"; 
    bytes1 public _byte = _byte32[0];

MiniSolidity 变量以字节的方式存储进变量 _byte32 ,转换成 16进制 为: 0x4d696e69536f6c69646974790000000000000000000000000000000000000000

_byte 变量存储 _byte32 的第一个字节,为 0x4d

  1. 枚举类型 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>)]
  1. function 用来声明一个函数,固定用法,这与 python 用 def 来声明一个函数有些类似

  2. <function name> 函数名,自己定

  3. (<parameter types>) 传入函数中的参数

  4. {internal|external|public|private} 说明函数的类型,未标明默认函数类型为 internal .

    • public : 内部外部均可见。(也可用于修饰状态变量,public 变量会自动生成  getter 函数,用于查询数值).
    • private : 只能从本合约内部访问,继承的合约也不能用(也可用于修饰状态变量)
    • external : 只能从合约外部访问(但是可以用 this.f() 来调用, f 是函数名)
    • internal : 只能从合约内部访问,继承的合约可以用(也可用于修饰状态变量)
  5. [pure|view|payable] 决定函数权限 / 功能的关键字。带有 payable 的函数可以用来给合约转入 ETH, 而 pureview , 根据 wtf 大佬的说法,是因为 gas fee 的原因,合约的状态变量存储在链上, gas fee 很贵,如果不改变链上状态,就不用付 gas 。包含 pureview 关键字的函数是不改写链上状态的,因此用户直接调用他们是不需要付 gas 的(合约中 pure / view 函数调用它们则会改写链上状态,需要付 gas)。

    在以太坊中,以下语句被视为修改链上状态:

    1. 写入状态变量。
    2. 释放事件。
    3. 创建其他合同。
    4. 使用 selfdestruct .
    5. 通过调用发送以太币。
    6. 调用任何未标记 viewpure 的函数。
    7. 使用低级调用(low-level calls)。
    8. 使用包含某些操作码的内联汇编。

    这里总结一下三个关键字的作用

    • pure 不能读写
    • view 能读不能写
    • payable 可以用来交易
  6. [returns ()] 函数返回的变量类型和名称。

# solidity 的函数输出

solidity 支持函数返回多个值,solidity 函数输出的关键字为 returnreturns

  • 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 数据存储位置有三类: storagememorycalldata 。不同存储位置的 gas 成本不同。 storage 类型的数据存在链上,类似计算机的硬盘,消耗 gas 多; memorycalldata 类型的临时存在内存里,消耗 gas 少。大致用法:

  1. storage :合约里的状态变量默认都是 storage ,存储在链上。
  2. memory :函数里的参数和临时变量一般用 memory ,存储在内存中,不上链。
  3. calldata :和 memory 类似,存储在内存中,不上链。与 memory 的不同点在于 calldata 变量不能修改( immutable ),一般用于函数的参数。例子:
function fCalldata(uint[] calldata _x) public pure returns(uint[] calldata){
        // 参数为 calldata 数组,不能被修改
        //_x [0] = 0 // 这样修改会报错
        return(_x);
    }

# 数据位置和赋值规则

  1. storage (合约的状态变量)赋值给本地 storage (函数里的)时候,会创建引用,改变新变量会影响原变量。例子:
uint[] x = [1,2,3]; // 状态变量:数组 x
    function fStorage() public{
        // 声明一个 storage 的变量 xStorage,指向 x。修改 xStorage 也会影响 x
        uint[] storage xStorage = x;
        xStorage[0] = 100;
    }
  1. 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;
    }
  1. 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;
}
  1. 其他情况,变量赋值给 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

Untitled

  • Ruletwo

Untitled

  • Rulethree

Untitled

  • Rulefour

Untitled

# 这里用一张关系图来总结一下这四种规则

Untitled

# 变量的作用域

Solidity 中变量按作用域划分有三种,分别是状态变量(state variable),局部变量(local variable)和全局变量 (global variable)

  1. 状态变量
    指的是存储在链上的变量,所有合约内的函数都可以访问,定义在合约内,函数外,可以在函数内修改状态变量的值,gas 消耗高.
contract Variables {
    uint public x = 1;
    uint public y;
    string public z;
  1. 局部变量
    存储在内存中,gas 消耗低
function bar() external pure returns(uint){
        uint xx = 1;
        uint yy = 3;
        uint zz = xx + yy;
        return(zz);
    }
  1. 全局变量
    全局变量是全局范围工作的变量,都是 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 ): 当前链 id
  • block.coinbase  (  address  ): 挖出当前区块的矿工地址
  • block.difficulty  (  uint  ): 当前区块难度
  • block.gaslimit  (  uint  ): 当前区块 gas 限额
  • block.number  (  uint  ): 当前区块号
  • block.timestamp  (  uint ): 自 unix epoch 起始当前区块以秒计的时间戳
  • gasleft() returns (uint256)  :剩余的 gas
  • msg.data  (  bytes  ): 完整的 calldata
  • msg.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: 在函数中创建一个 storagestruct 引用
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 分别是 KeyValue 的变量类型。例子:

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 默认的类型,比如 uintaddress 等,不能用自定义的结构体。而 _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 会默认为新定义的变量赋初值,具体如下

# 值类型初始值

  • booleanfalse
  • string""
  • int0
  • uint0
  • enum : 枚举中的第一个元素
  • address0x0000000000000000000000000000000000000000  (或  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 . 另外,只有数值变量可以声明 constantimmutablestringbytes 可以声明为 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 语言类比,因为逻辑基本上是一样的

  1. if-else
function ifElseTest(uint256 _number) public pure returns(bool){
    if(_number == 0){
    return(true);
    }else{
    return(false);
    }
}
  1. for循环
function forLoopTest() public pure returns(uint256){
    uint sum = 0;
    for(uint i = 0; i < 10; i++){
    sum += i;
    }
    return(sum);
}
  1. while循环
function whileTest() public pure returns(uint256){
    uint sum = 0;
    uint i = 0;
    while(i < 10){
    sum += i;
    i++;
    }
    return(sum);
}
  1. do-while循环
function doWhileTest() public pure returns(uint256){
    uint sum = 0;
    uint i = 0;
    do{
    sum += i;
    i++;
    }while(i < 10);
    return(sum);
}
  1. 三元运算符  三元运算符是 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,000  gas ;相比之下,链上存储一个新变量至少需要 20,000  gas

# 规则

事件的声明由 event 关键字开头,然后跟事件名称,括号里面写好事件需要记录的变量类型和变量名。以 ERC20 代币合约的 Transfer 事件为例:

event Transfer(address indexed from, address indexed to, uint256 value);

我们可以看到, Transfer 事件共记录了 3 个变量 fromtovalue ,分别对应代币的转账地址,接收地址和转账数量。

同时 fromto 前面带着 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 个 functionhip()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” ,并且还分别从 YeyeBaba 合约继承了 yeye()baba() 两个函数。

# 修饰器的继承

Solidity 中的修饰器( Modifier )同样可以继承,用法与函数继承类似,在相应的地方加 virtualoverride 关键字即可。

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;
    }
}
  1. 在继承时声明父构造函数的参数,例如: contract B is A(1)
  2. 在子合约的构造函数中声明构造函数的参数,例如:
contract C is A {
    constructor(uint _c) A(_c * _c) {}
}

# 调用父合约的函数

子合约有两种方式调用父合约的函数,直接调用和利用 super 关键字。

  1. 直接调用:子合约可以直接用 父合约名.函数名() 的方式来调用父合约函数,例如 Yeye.pop()
function callParent() public{
        Yeye.pop();
    }
  1. super 关键字:子合约可以利用 super.函数名() 来调用最近的父合约函数。 solidity 继承关系按声明时从右到左的顺序是: contract Erzi is Yeye, Baba ,那么 Baba 是最近的父合约, super.pop() 将调用 Baba.pop() 而不是 Yeye.pop()
function callParentSuper() public{
        // 将调用最近的父合约函数,Baba.pop ()
        super.pop();
    }

# solidity 中的异常

solidity 三种抛出异常的方法: errorrequireassert

# 条件检查

# Error

errorsolidity 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 关键字已被弃用,将来会被淘汰。)回滚所有状态改变,返回” 无效操作代码错误”,而且消耗掉剩下的 gas
  • revert :函数可以用来标记错误并回退当前调用,允许返回一个数值,将剩余 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";
    }
}

Untitled

Untitled

# 阅读一些智能合约


# 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 中文文档 - 登链社区
更新于 阅读次数