Solidity学习

目录

参考1:https://github.com/AmazingAng/WTFSolidity

参考2:https://space.bilibili.com/286084162/channel/collectiondetail?sid=296410

数值类型

// 数值类型
// bool类型
bool public _bool = true;

// 整型
int public _int = -1;
uint public _uint = 1;
uint256 public _number = 20220930;
// 整数运算
uint256 public _number1 = _number + 1; // +,-,*,/
uint256 public _number2 = 2**2; // 指数
uint256 public _number3 = 7 % 2; // 取余数
bool public _numberbool = _number2 > _number3; // 比大小

// 地址类型,address类型存储一个20字节的值(以太坊地址的大小)。有普通的地址类型和可以转账ETH的地址类型(payable)。payable的地址拥有balance和tranfer(),方便查询和转账。
// 地址
address public _address = 0x7A58c0Be72BE218B41C608b7Fe7C5bB630736C71;
address payable public _address1 = payable(_address); // payable address,可以转账、查余额
// 地址类型的成员
uint256 public balance = _address1.balance; // balance of address

// 定长字节数组
bytes32 public _byte32 = "MiniSolidity";
bytes1 public _byte = _byte32[0];

// 枚举enum,可以显示和uint转换,几乎没人使用。
enum ActionSet { Buy, Hold, Sell }
ActionSet action = ActionSet.Buy;

引用类型

数组和结构体

数组 array

固定长度和变长

数组分为固定长度数组和可变长度数组两种:

// 固定长度数组
uint[8] array1;
bytes1[5] array2;
address[100] array3;

// 可变长数组,声明时不指定数组的长度
uint[] array4;
bytes1[] array5;
address[] array6;
bytes array7;

// 注意:bytes比较特殊,是数组,但是不加[]。
// 另外不能用byte[]声明单字节数组,可以使用bytes或bytes1[]

创建数组规则

  • 对于memory修饰的动态数组,可以用new操作符来创建,但是必须声明长度,并且声明后长度不可变。
  • 创建动态数组需要一个一个赋值。
// memory动态数组
uint[] memory array8 = new uint[](3);
bytes memory array9 = new bytes(9);

// 动态数组赋值
array8[0] = 1
array9[1] = 2

数组成员

  • length: 数组有一个包含元素数量的length成员,memory数组的长度在创建后是固定的。
  • push(): 动态数组和bytes拥有push()成员,可以在数组最后添加一个0元素。
  • push(x): 动态数组和bytes拥有push(x)成员,可以在数组最后添加一个x元素。
  • pop(): 动态数组和bytes拥有pop()成员,可以移除数组最后一个元素。

结构体

// 结构体
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;
}

映射

映射规则

  • 规则1:映射的_KeyType只能选择solidity默认的类型,比如uint,address等,不能用自定义的结构体。而_ValueType可以使用自定义的类型。
  • 规则2:映射的存储位置必须是storage,因此可以用于合约的状态变量,函数中的storage变量。不能用于public函数的参数或返回结果中,因为mapping记录的是一种关系 (key - value pair)。
  • 规则3:如果映射声明为public,那么solidity会自动给你创建一个getter函数,可以通过Key来查询对应的Value。
  • 规则4:给映射新增的键值对的语法为_Var[_Key] = _Value,其中_Var是映射变量名,_Key和_Value对应新增的键值对。

变量

变量初始值

bool public _bool; // false
string public _string; // ""
int public _int; // 0
uint public _uint; // 0
address public _address; // 0x0000000000000000000000000000000000000000

enum ActionSet { Buy, Hold, Sell}
ActionSet public _enum; // 第1个内容Buy的索引0

function fi() internal{} // internal空白方程 
function fe() external{} // external空白方程 

引用类型初始值

// Reference Types
uint[8] public _staticArray; // 所有成员设为其默认值的静态数组[0,0,0,0,0,0,0,0]
uint[] public _dynamicArray; // `[]`
mapping(uint => address) public _mapping; // 所有元素都为其默认值的mapping
// 所有成员设为其默认值的结构体 0, 0
struct Student{
    uint256 id;
    uint256 score; 
}
Student public student;

delete操作符

delete操作符可以让变量a的值变成初始值。

常数constant和immutable

constant变量必须声明时初始化,之后不能改变。

immutable变量可以在声明构造函数中初始化,之后不能改变,比constant变量更加灵活。也可以使用全局变量自定义函数给immutable变量初始化。

// immutable变量可以在constructor里初始化,之后不能改变
uint256 public immutable IMMUTABLE_NUM = 9999999999;
address public immutable IMMUTABLE_ADDRESS;
uint256 public immutable IMMUTABLE_BLOCK;
uint256 public immutable IMMUTABLE_TEST;

// 利用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);
}

函数

函数作用域

{internal|external|public|private}:函数可见性说明符,一共4种。没标明函数类型的,默认****internal

  • public: 内部外部均可见。(也可用于修饰状态变量,public变量会自动生成 getter函数,用于查询数值).
  • private: 只能从本合约内部访问,继承的合约也不能用(也可用于修饰状态变量)。
  • external: 只能从合约外部访问(但是可以用this.f()来调用,f是函数名)
  • internal: 只能从合约内部访问,继承的合约可以用(也可用于修饰状态变量)。

函数权限/功能关键字

[pure|view|payable]:决定函数权限/功能的关键字。payable(可支付的)很好理解,带着它的函数,运行的时候可以给合约转入ETH。

  • pure,修饰函数,表示该函数不会读取或修改合约的状态(即不会访问或修改存储变量、合约余额等)。
  • view,函数可以读取状态但不能修改。
  • 默认情况,可以读写,链上状态。

函数返回值

// 返回多个变量
function returnMultiple() public pure returns(uint256, bool, uint256[3] memory){
    return(1, true, [uint256(1),2,5]);
}

// 命名式返回
function returnNamed() public pure returns(uint256 _number, bool _bool, uint256[3] memory _array){
    _number = 2;
    _bool = false; 
    _array = [uint256(3),2,1];
}

// 解析式赋值
uint256 _number;
bool _bool;
uint256[3] memory _array;
(_number, _bool, _array) = returnNamed();

// 读取部分值
(, _bool2, ) = returnNamed();

构造函数

构造函数每个合约只能定义一个,在部署时自动运行一次,用来初始化一些参数。

   address owner; // 定义owner变量

   // 构造函数
   constructor() {
      owner = msg.sender; // 在部署合约的时候,将owner设置为部署者的地址
   }

修饰器

修饰器(modifier)声明函数拥有的特性,减少代码冗余。

 // 定义modifier
 modifier onlyOwner {
    require(msg.sender == owner); // 检查调用者是否为owner地址
    _; // 如果是的话,继续运行函数主体;否则报错并revert交易
 }

 function changeOwner(address _newOwner) external onlyOwner{
    owner = _newOwner; // 只有owner地址运行这个函数,并改变owner
 }

OpenZepplin的Ownable标准实现:

openzeppelin-contracts/Ownable.sol at master · OpenZeppelin/openzeppelin-contracts

函数重载

Solidity运行函数重载,即函数名称相同参数不同。但是Solidity不允许修饰器(modifier)重载。

function saySomething() public pure returns(string memory){
    return("Nothing");
}

function saySomething(string memory something) public pure returns(string memory){
    return(something);
}

实参匹配

在调用重载函数时,会把输入实际参数和函数参数变量类型做匹配。如果出现多个匹配的重载函数,则会报错。

function f(uint8 _in) public pure returns (uint8 out) {
    out = _in;
}

function f(uint256 _in) public pure returns (uint256 out) {
    out = _in;
}

调用f(50),50可以被转换成uint8也可以被转换为uint256,因此会报错。

事件(events)

Solidity中的事件(event)是EVM上日志的抽象,它具有两个特点:

  • 响应:应用程序(ether.js)可以通过RPC接口订阅和监听事件,并在前端做响应。
  • 经济:事件是EVM上比较经济的存储数据的方式,每个大概消耗2,000 gas;相比之下,链上存储一个新变量至少需要20,000 gas。

声明事件

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

Transfer事件记录了3个变量from,to和value,分别对应代币的转账地址,接收地址和转账数量,其中from和to前面带有indexed关键字,他们会保存在以太坊虚拟机日志的topics中,方便之后检索。

释放事件

// 定义_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);
}

EVM日志

以太坊虚拟机(EVM)用日志Log来存储Solidity事件,每条日志记录都包含主题topics和数据data两部分。

主题 topics

日志的第一部分是主题数组,用于描述事件,长度不能超过4。它的第一个元素是事件的签名(哈希)。对于上面的Transfer事件,它的签名就是:

keccak256("Transfer(addrses,address,uint256)")

除了事件签名,主题还可以包含至多3个indexed参数,也就是Transfer事件中的from和to。

indexed标记的参数可以理解为检索事件的索引“键”,方便之后搜索。每个 indexed 参数的大小为固定的256比特,如果参数太大了(比如字符串),就会自动计算哈希存储在主题中。

数据 data

事件中不带 indexed的参数会被存储在 data 部分中,可以理解为事件的“值”。data 部分的变量不能被直接检索,但可以存储任意大小的数据。因此一般 data 部分可以用来存储复杂的数据结构,例如数组和字符串等等,因为这些数据超过了256比特,即使存储在事件的 topic 部分中,也是以哈希的方式存储。另外,data 部分的变量在存储上消耗的gas相比于 topic 更少。

变量存储和作用域

引用类型

引用类型包括数组,结构体和映射,这类变量占用空间大,赋值时直接传递地址。我们在使用时必须声明数据存储的位置。string类型使用时也需要声明存储位置(string类型本质是数组类型)。

数据存储位置

solidity数据存储位置有三类:storage,memory和calldata。不同存储位置gas成本不同,storage类型的数据存储在链上,消耗gas多,memory和calldata类型临时存储在内存中,消耗gas少。

  • storage存储的是状态变量。
  • memory存储的是局部变量。
  • calldata和memory相似,但是只能用在输入参数中,如果是calldata能够节约gas。
pragma solidity ^0.8.13;

contract DataLocations {
    struct MyStruct {
        uint foo;
        string bar;
    }

    mapping(address => MyStruct) public myStructs;

    constructor() {
        // init
        myStructs[msg.sender] = MyStruct({foo: 123, bar: "bar"});
    }

    function examples(uint[] memory y, uint[] calldata z) external returns (uint[] memory) {
        // storage可以操作状态变量,修改生效
        MyStruct storage myStruct = myStructs[msg.sender];
        myStruct.foo = 234;

        // memory修改临时内存变量,修改不生效
        MyStruct memory myStruct1 = myStructs[msg.sender];
        myStruct1.foo = 345;

        _internal(y);
        _internal01(z);

        // 内存中数组(局部变量)必须是定长数组,变长数组只能用到状态变量中。
        uint[] memory memArr = new uint[](3);
        memArr[0] = 111;

        return memArr;
    }

    function _internal(uint[] memory y) private pure {
        uint x = y[0];
    }

    function _internal01(uint[] calldata z) private pure {
        uint x = z[0];
    }
}

变量的作用域

  • 状态变量:状态变量是数据存储在链上的变量。
  • 局部变量:局部变量的数据存储在内存里,不上链,低gas,仅在函数执行过程中有效。
  • 全局变量:全局范围工作的变量,都是solidity预留关键字。可以直接使用。
    • blockhash(uint blockNumber): (bytes32)给定区块的哈希值 – 只适用于256最近区块, 不包含当前区块。
    • block.coinbase: (address payable) 当前区块矿工的地址
    • block.gaslimit: (uint) 当前区块的gaslimit
    • block.number: (uint) 当前区块的number
    • block.timestamp: (uint) 当前区块的时间戳,为unix纪元以来的秒
    • gasleft(): (uint256) 剩余 gas
    • msg.data: (bytes calldata) 完整call data
    • msg.sender: (address payable) 消息发送者 (当前 caller)
    • msg.sig: (bytes4) calldata的前四个字节 (function identifier)
    • msg.value: (uint) 当前交易发送的wei值

控制流

if-else/for/while/do-while/三元运算符。

// 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);
}

// 三元运算符
function ternaryTest(uint256 x, uint256 y) public pure returns(uint256){
    // return the max of x and y
    return x >= y ? x: y; 
}

继承

继承规则

  • virtual:父合约中的函数,如果希望子合约重写,需要加上virtual关键字。
  • override:子合约重写了父合约的函数,需要加上override关键字。

注意:用override修饰public变量,会重写与变量同名的getter函数,例如:

mapping(address => uint256) public override balanceOf;

简单继承

contract Yeye {
  event Log(string msg);

  function hip() public ‘,{
      emit Log("Yeye");
  }

  function pop() public virtual{
      emit Log("Yeye");
  }

  function yeye() public virtual {
      emit Log("Yeye");
  } 
}

// 重写了Yeye合约的hip()和pop()函数,继承了yeye()函数
contract Baba is Yeye {
  function hip() public virtual override {
      emit Log("Baba");
  }

  function pop() public virtual override {
      emit Log("Baba");
  }

  function baba() public virtual {
      emit Log("Baba");
  } 
}

多重继承

注意:继承时要按辈分最高到最低排序,如果contract Erzi is Baba, Yeye就会报错。

        <font style="color:#E8323C;">如果某一个函数在多个继承合约里都存在,比如hip()和pop(),在子合约里必须重写</font>,否则报错。

重写在多个父合约中重名函数时,override关键字后面要加上所有父合约名字。
contract Erzi is Yeye, Baba {
  function hip() public virtual override(Yeye, Baba){
      emit Log("Erzi");
  }

  function pop() public virtual override(Yeye, Baba) {
      emit Log("Erzi");
  }
}

修饰器的继承

修饰器的继承和函数继承类似,在相应地方加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);
    }
}

也可以重写修饰器

modifier exactDividedBy2And3(uint _a) override {
    _;
}

构造函数的继承

contract S {
  string public name;

  constructor(string memory _name) {
    name = _name;
  }
}

contract T {
  string public text;

  constructor(string memory _text) {
    text = _text;
  }
}

// 方式一:在继承时直接传入参数,但是需要提前知道参数的值
contract U is S("qinyang"), T("love") {
}

// 方式二:在子合约里面实现构造方法时传入
contract V is S, T {
  constructor(string memory _name, string memory _text) S(_name) T(_text) {
  }
}

构造函数是执行顺序,按照是继承顺序,S->T->U。

调用父合约函数

// 通过合约名字直接调用
function callParent() public {
  Yeye.pop();
}

// super关键字调用
function callParentSuper() public {
  super.pop();
}

多重继承+菱形继承

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

/* 继承树:
  God
 /  \
Adam Eve
 \  /
people
*/

contract God {
    event Log(string message);

    function foo() public virtual {
        emit Log("God.foo called");
    }

    function bar() public virtual {
        emit Log("God.bar called");
    }
}

contract Adam is God {
    function foo() public virtual override {
        emit Log("Adam.foo called");
        Adam.foo();
    }

    function bar() public virtual override {
        emit Log("Adam.bar called");
        super.bar();
    }
}

contract Eve is God {
    function foo() public virtual override {
        emit Log("Eve.foo called");
        Eve.foo();
    }

    function bar() public virtual override {
        emit Log("Eve.bar called");
        super.bar();
    }
}

contract people is Adam, Eve {
    function foo() public override(Adam, Eve) {
        super.foo();
    }

    function bar() public override(Adam, Eve) {
        super.bar();
    }
}

在这个例子中,调用合约people中的super.bar()会依次调用Eve、Adam,最后是God合约。

虽然Eve、Adam都是God的子合约,但整个过程中God合约只会被调用一次。具体原因是Solidity借鉴了Python的方式,强制一个由基类构成的DAG(有向无环图)使其保证一个特定的顺序。

抽象合约和接口

抽象合约

如果一个智能合约里至少有一个未实现的函数,即某个函数缺少主体{}中的内容,则必须将该合约标为abstract,不然编译会报错;另外,未实现的函数需要加virtual,以便子合约重写。

abstract contract InsertionSort{
    function insertionSort(uint[] memory a) public pure virtual returns(uint[] memory);
}

接口

接口类似抽象合约,但不实现任何功能。接口规则:

  • 没有状态变量。
  • 没有构造函数。
  • 不能继承除接口以外的其它合约。
  • 所有函数必须是external且不能有函数体。
  • 继承接口合约,必须实现接口定义的所有函数。

接口与合约ABI(Application Binary Interface)等价,可以相互转换:编译接口可以得到合约的ABI,利用abi-to-sol工具也可以将ABI json文件转换为接口sol文件。

异常

Solidity有三种抛出异常的方法:error,require和assert。

Error

error TransferNotOwner(); // 自定义error
error TransferNotOwner(address sender); // 自定义的带参数的error
// Error必须和revert命令使用
function transferOwner1(uint256 tokenId, address newOwner) public {
    if(_owners[tokenId] != msg.sender){
        revert TransferNotOwner();
        // revert TransferNotOwner(msg.sender);
    }
    _owners[tokenId] = newOwner;
}

Require

// Require require(检查条件,"异常的描述"),当检查条件不成立的时候,就会抛出异常。
// 缺点就是异常的描述越长,越消耗gas
function transferOwner2(uint256 tokenId, address newOwner) public {
    require(_owners[tokenId] == msg.sender, "Transfer Not Owner");
    _owners[tokenId] = newOwner;
}

Assert

// Assert 一般用于编写程序是debug,不能描述异常原因。
function transferOwner3(uint256 tokenId, address newOwner) public {
    assert(_owners[tokenId] == msg.sender);
    _owners[tokenId] = newOwner;
}

库合约

库函数

库函数是一种特殊的合约,为了提升solidity代码的复用性和减少gas而存在。库合约一般都是一些好用的函数合集(库函数),由大神或者项目方创作,咱们站在巨人的肩膀上,会用就行了。

它和普通合约有几点不同:不可以存在状态变量;不能被继承或继承;不能接受以太币;不可以被销毁。

如何使用库合约

利用using for指令

指令**using A for B;**可用于附加库函数(从库A)到任何类型(B)。添加完指令后,库A中的函数会自动添加为B类型变量的成员,可以直接调用。注意,在调用时,这个变量会被当做第一个参数传递给函数。

// 利用using for指令
using Strings for uint256;
function getString1(uint256 _number) public pure returns(string memory){
    // 库函数会自动添加为uint256型变量的成员
    return _number.toHexString();
}

通过库合约名称调用库函数

// 直接通过库合约名调用
function getString2(uint256 _number) public pure returns(string memory){
    return Strings.toHexString(_number);
}

常用库合约

  • String:将uint256转换为String。
  • Address:判断某个地址是否为合约地址。
  • Create2:更安全的使用Create2 EVM opcode。
  • Arrays:跟数组相关的库函数。

Import

Solidity支持利用import关键字导入其它合约中的全局符号。一般不具体指定则将导入文件的所有全局符号到当前全局作用域中。

通过源文件相对位置导入

文件结构
├── Import.sol
└── Yeye.sol

// 通过文件相对位置import
import './Yeye.sol';

通过源文件网址导入

// 通过网址引用
import 'https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/Address.sol';

通过npm目录导入

import '@openzeppelin/contracts/access/Ownable.sol';

通过指定全局符号导入

import {Yeye} from './Yeye.sol';

接受ETH receive和fallback

Solidity支持两种特殊的回调函数,receive()和fallback(),他们主要在两种情况下被使用:

  • 接收ETH。
  • 处理合约中不存在的函数调用(代理合约proxy contract)。

接受ETH函数 receive

receive()只用于处理接收ETH。一个合约最多有一个receive()函数,声明方式与一般函数不一样,不需要function关键字:receive() external payable { … }。receive()函数不能有任何的参数不能返回任何值必须包含external和payable

当合约接收ETH的时候,receive()会被触发。receive()最好不要执行太多的逻辑因为如果别人用send和transfer方法发送ETH的话,gas会限制在2300,receive()太复杂可能会触发Out of Gas报错;如果用call就可以自定义gas执行更复杂的逻辑(这三种发送ETH的方法我们之后会讲到)。

我们可以在receive()里发送一个event,例如:

// 定义事件
event Received(address Sender, uint Value);
// 接收ETH时释放Received事件
receive() external payable {
    emit Received(msg.sender, msg.value);
}

回退函数 fallback

fallback()函数会在调用合约不存在的函数时被触发。可用于接收ETH,也可以用于代理合约proxy contract。fallback()声明时不需要function关键字,必须由external修饰,一般也会用payable修饰,用于接收ETH:fallback() external payable { … }。

我们定义一个fallback()函数,被触发时候会释放fallbackCalled事件,并输出msg.sender,msg.value和msg.data:

// fallback
fallback() external payable{
    emit fallbackCalled(msg.sender, msg.value, msg.data);
}

receive和fallback的区别

receive和fallback都能够用于接收ETH,他们触发的规则如下:

触发fallback() 还是 receive()?
           接收ETH
              |
         msg.data是空
            /  \
              
          /      \
receive()存在?   fallback()
        / \
         
      /     \
receive()   fallback()

简单来说,合约接收ETH时,msg.data为空且存在receive()时,会触发receive();msg.data不为空或不存在receive()时,会触发fallback(),此时fallback()必须为payable。

receive()和payable fallback()均不存在的时候,向合约发送ETH将会报错。

发送ETH

Solidity有三种方法向其他合约发送ETH,他们是:transfer(),send()和call(),其中call()是被鼓励的用法。

  • call没有gas限制,最为灵活,是最提倡的方法;但是必须要了解call()被错误使用时会导致重入风险。
  • transfer有2300 gas限制,但是发送失败会自动revert交易,是次优选择;
  • send有2300 gas限制,而且发送失败不会自动revert交易,几乎没有人用它。

接收ETH合约

contract ReceiveETH {
	event Log(uint amount, uint gas);

  // receive方法,接收eth时被触发
  receive() external payable {
    emit Log(msg.value, gasleft());
  }

  // 返回合约中的ETH
  function getBalance() view public returns(uint) {
    return address(this).balance;
  }
}

发送ETH合约

首先,先在发送ETH合约SendETH中实现payable的构造函数和receive(),让我们能够在部署时和部署后向合约转账。

contract SendETH {
    // 构造函数,payable使得部署的时候可以转eth进去
    constructor() payable{}
    // receive方法,接收eth时被触发
    receive() external payable{}
}

transfer

  • 用法是:接收方地址.transfer(发送ETH数额)。
  • transfer()的gas限制是2300,足够用于转账,但对方合约的fallback()或receive()函数不能实现太复杂的逻辑。
  • transfer()如果转账失败,会自动revert(回滚交易)。
// 用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();
    }
}

合约调用

通过合约代码(或接口)和地址调用目标合约函数

目标合约

contract OtherContract {
    uint256 private _x = 0; // 状态变量_x
    // 收到eth的事件,记录amount和gas
    event Log(uint amount, uint gas);
    
    // 返回合约ETH余额
    function getBalance() view public returns(uint) {
        return address(this).balance;
    }

    // 可以调整状态变量_x的函数,并且可以往合约转ETH (payable)
    function setX(uint256 x) external payable{
        _x = x;
        // 如果转入ETH,则释放Log事件
        if(msg.value > 0){
            emit Log(msg.value, gasleft());
        }
    }

    // 读取_x
    function getX() external view returns(uint x){
        x = _x;
    }
}

传入合约地址

传入目标合约地址,生成目标合约的引用,然后调用目标函数。

function callSetX(address _Address, uint256 x) external{
    OtherContract(_Address).setX(x);
}

传入合约变量

直接传入合约的引用,只需要把上面参数address类型改成目标合约名。

function callGetX(OtherContract _Address) external view returns(uint x){
    x = _Address.getX();
}

创建合约变量

function callGetX2(address _Address) external view returns(uint x){
    OtherContract oc = OtherContract(_Address);
    x = oc.getX();
}

调用合约发送ETH

如果目标合约的函数是payable的,那么我们可以通过调用它来给合约转账:_Name(_Address).f{value: _Value}(),其中_Name是合约名,_Address是合约地址,f是目标函数名,_Value是要转的ETH数额(以wei为单位)。

function setXTransferETH(address otherContract, uint256 x) payable external{
    OtherContract(otherContract).setX{value: msg.value}(x);
}

OtherContract合约的setX函数是payable的,在下面这个例子中我们通过调用setX来往目标合约转账。

低级调用 call

call是address类型的低级成员函数,它用来与其它合约交互,返回值为(bool, data)。data是目标函数的返回值。

  • call是solidity官方推荐的通过触发fallback或receive函数发送ETH的方法。
  • 不推荐用call来调用另一个合约,因为当你调用不安全合约的函数时,你就把主动权交给了它。推荐的方法仍是声明合约变量后调用函数。
  • 当我们不知道对方合约的源代码或ABI,就没法生成合约变量;这时,我们仍可以通过call调用对方合约的函数。

call使用规则

目标合约地址.call(二进制编码);

其中二进制编码通过结构化编码函数abi.encodeWithSignature获得:

abi.encodeWithSignature("函数签名", arg1, arg2, arg3 ...);

函数签名为函数名(逗号分隔的参数类型)。例如abi.encodeWithSignature(“f(uint256, address)”, _x, _addr);

call在调用合约时,可以指定交易发送ETH和gas:

目标合约地址.call{value: 发送数额, gas: gas数额}(二进制编码);

利用call调用目标合约

event Response(bool success, bytes data);

function callSetX(address payable _addr, uint256 x) external payable {
  (bool success, bytes memory data) = _addr.call{value: msg.value}(
    abi.encodeWithSignature("setX(uint256)", x)
  );
  emit Response(success, data);
}

function callGetX() external returns(uint256) {
  (bool success, bytes memory data) = _addr.call{value: msg.value}(
    abi.encodeWithSignature("getX()")
  );
  emit Response(success, data);
	return abi.decode(data, (uint256));
}

// 调用不存在的函数
function callNonExist(address _addr) external {
  (bool success, bytes memory data) = _addr.call{value: msg.value}(
    abi.encodeWithSignature("foo(uint256)")
  );
  emit Response(success, data);
}

call不存在的函数仍然会执行成功,并返回true,实际上调用目标合约fallback函数。

委托/代表调用 delegatecall

理解delegatecall

使用时必须牢记两件事delegatecall:

delegatecall保留上下文(存储、调用者等…)

delegatecall合约调用和合约被调用的存储布局必须相同

delegatecall也是solidity中address类型的低级成员函数。delegate委托/代表了什么?

当用户A通过合约B来call合约C的时候,执行的是合约C的函数,语境(Context,可以理解为包含变量和状态的环境)也是合约C的:msg.sender是B的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约C的变量上。

而当用户A通过合约B来delegatecall合约C的时候,执行的是合约C的函数,但是语境仍是合约B的:msg.sender是A的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约B的变量上。

delegatecall使用场景

  • 代理合约:将智能合约的存储合约和逻辑合约分开:代理合约存储所有相关变量,并保存逻辑合约地址;所有函数存在逻辑合约里,通过delegatecall执行。当升级时,只需要将代理合约指向新的逻辑合约即可。(数据在代理合约,代理合约通过delegatecall调用逻辑合约,对于逻辑合约来说,语境是合约B的语境。需要升级逻辑时,只需要升级逻辑合约。)
  • EIP-2535 Diamonds。

delegatecall使用规则(同call调用)

目标合约地址.delegatecall(二进制编码);

和call不一样的是,delegatecall调用合约时可以指定交易发送的gas,但不能指定发送的ETH数额

delegatecall使用

//被调用的合约C 
contract C {
	uint public num;
  address public sender;

  function setVars(uint _num) public payable {
    num = _num;
    sender = msg.sender;
  } 
}

//发起调用的合约B 
contract B {
  uint public num;
  address public sender;

  function callSetVars(uint _num, address _addr) public payable {
    (bool success, bytes data) = _addr.call(
      abi.encodeWithSignature("setVars(uint)", _num)
    );
  } 

  function delegatecallSetVars(uint _num, address _addr) public payable {
    (bool success, bytes data) = _addr.delegatecall(
      abi.encodeWithSignature("setVars(uint)", _num)
    );
  }
}

合约B必须和目标合约C的变量存储布局必须相同,两个变量,并且顺序为num和sender

在合约中创建新的合约

在以太坊链上,用户(外部账户,EOA)可以创建智能合约,智能合约同样也可以创建新的智能合约。去中心化交易所uniswap就是利用工厂合约(Factory)创建了无数个币对合约(Pair)。

create和create2

可以通过create或create2在合约中创建合约。

create

create用法很简单,就是new一个合约,并传入新合约构造函数需要的参数:

Contract x = new Contract{value: _value}(params)

其中Contract是要创建的合约名,x是合约对象(地址),如果构造函数是payable,可以创建时转入_value数量的ETH,params是新合约构造函数的参数。

create如何计算地址

智能合约可以由其他合约和普通账户利用CREATE操作码创建。 在这两种情况下,新合约的地址都以相同的方式计算:创建者的地址(通常为部署的钱包地址或者合约地址)和nonce(该地址发送交易的总数,对于合约账户是创建的合约总数,每创建一个合约nonce+1))的哈希。

新地址 = hash(创建者地址, nonce)

创建者地址不会变,但nonce可能会随时间而改变,因此用CREATE创建的合约地址不好预测。

create2

CREATE2 操作码使我们在智能合约部署在以太坊网络之前就能预测合约的地址。Uniswap创建Pair合约用的就是CREATE2而不是CREATE。

create2用法和create类似,只不过多传了一个salt参数:

Contract x = new Contract{salt: _salt, value: _value}(params)

_salt是盐。

create2如何计算地址

CREATE2的目的是为了让合约地址独立于未来的事件。不管未来区块链上发生了什么,你都可以把合约部署在事先计算好的地址上。用CREATE2创建的合约地址由4个部分决定:

  • 0xFF:一个常数,避免和CREATE冲突
  • 创建者地址
  • salt(盐):一个创建者给定的数值
  • 待部署合约的字节码(bytecode)
新地址 = hash("0xFF",创建者地址, salt, bytecode)

CREATE2 确保,如果创建者使用 CREATE2 和提供的 salt 部署给定的合约bytecode,它将存储在 新地址 中。

极简Uniswap 版本一

UniswapV2Pair: 币对合约,用于管理币对地址、流动性、买卖。

UniswapV2Factory: 工厂合约,用于创建新的币对,并管理币对地址。

下面我们用create方法实现一个极简版的Uniswap:Pair币对合约负责管理币对地址,PairFactory工厂合约用于创建新的币对,并管理币对地址。

contract Pair{
    address public factory; // 工厂合约地址
    address public token0; // 代币1
    address public token1; // 代币2

    constructor() payable {
        factory = msg.sender;
    }

    // called once by the factory at time of deployment
    function initialize(address _token0, address _token1) external {
        require(msg.sender == factory, 'UniswapV2: FORBIDDEN'); // sufficient check
        token0 = _token0;
        token1 = _token1;
    }
}

contract PairFactory{
    mapping(address => mapping(address => address)) public getPair; // 通过两个代币地址查Pair地址
    address[] public allPairs; // 保存所有Pair地址

    function createPair(address tokenA, address tokenB) external returns (address pairAddr) {
        // 创建新合约
        Pair pair = new Pair(); 
        // 调用新合约的initialize方法
        pair.initialize(tokenA, tokenB);
        // 更新地址map
        pairAddr = address(pair);
        allPairs.push(pairAddr);
        getPair[tokenA][tokenB] = pairAddr;
        getPair[tokenB][tokenA] = pairAddr;
    }
}

//测试参数:
//WBNB地址: 0x2c44b726ADF1963cA47Af88B284C06f30380fC78
//BSC链上的PEOPLE地址:
//0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c

极简Uniswap 版本二

和版本一相比,主要不同在于new Pair时,使用了create2

contract Pair{
address public factory; // 工厂合约地址
address public token0; // 代币1
address public token1; // 代币2

constructor() payable {
    factory = msg.sender;
}

// called once by the factory at time of deployment
function initialize(address _token0, address _token1) external {
    require(msg.sender == factory, 'UniswapV2: FORBIDDEN'); // sufficient check
    token0 = _token0;
    token1 = _token1;
}
}

contract PairFactory2{
    mapping(address => mapping(address => address)) public getPair; // 通过两个代币地址查Pair地址
    address[] public allPairs; // 保存所有Pair地址

    function createPair2(address tokenA, address tokenB) external returns (address pairAddr) {
        require(tokenA != tokenB, 'IDENTICAL_ADDRESSES'); //避免tokenA和tokenB相同产生的冲突
        // 计算用tokenA和tokenB地址计算salt
        (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); //将tokenA和tokenB按大小排序
        bytes32 salt = keccak256(abi.encodePacked(token0, token1));
        // 用create2部署新合约
        Pair pair = new Pair{salt: salt}(); 
        // 调用新合约的initialize方法
        pair.initialize(tokenA, tokenB);
        // 更新地址map
        pairAddr = address(pair);
        allPairs.push(pairAddr);
        getPair[tokenA][tokenB] = pairAddr;
        getPair[tokenB][tokenA] = pairAddr;
    }

//测试参数:
//WBNB地址: 0x2c44b726ADF1963cA47Af88B284C06f30380fC78
//BSC链上的PEOPLE地址:
//0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c

事先计算Pair地址

// 提前计算pair合约地址
function calculateAddr(address tokenA, address tokenB) public view returns(address predictedAddress){
    require(tokenA != tokenB, 'IDENTICAL_ADDRESSES'); //避免tokenA和tokenB相同产生的冲突
    // 计算用tokenA和tokenB地址计算salt
    (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); //将tokenA和tokenB按大小排序
    bytes32 salt = keccak256(abi.encodePacked(token0, token1));
    // 计算合约地址方法 hash()
    predictedAddress = address(uint160(uint(keccak256(abi.encodePacked(
        bytes1(0xff),
        address(this),
        salt,
        keccak256(type(Pair).creationCode)
    )))));
}

create2的实际应用场景

  • 交易所为新用户预留创建钱包合约地址。
  • 由 CREATE2 驱动的 factory 合约,在uniswapV2中交易对的创建是在 Factory中调用create2完成。这样做的好处是: 它可以得到一个确定的pair地址, 使得 Router中就可以通过 (tokenA, tokenB) 计算出pair地址, 不再需要执行一次 Factory.getPair(tokenA, tokenB) 的跨合约调用。

删除合约

selfdestruct

selfdestruct命令可以用来删除智能合约,并将该合约剩余ETH转到指定地址。

如何使用selfdestruct

selfdestruct(_addr);

_addr就是接受合约剩余ETH的地址。

案例

contract DeleteContract {

    uint public value = 10;

    constructor() payable {}

    receive() external payable {}

    function deleteContract() external {
        // 调用selfdestruct销毁合约,并把剩余的ETH转给msg.sender
        selfdestruct(payable(msg.sender));
    }

    function getBalance() external view returns(uint balance){
        balance = address(this).balance;
    }
}

注意事项

  • 对外提供合约销毁接口时,最好设置为只有合约所有者可以调用,可以使用函数修饰符onlyOwner进行函数声明。
  • 当合约被销毁后与智能合约的交互也能成功,并且返回0。
  • 当合约中有selfdestruct功能时常常会带来安全问题和信任问题,合约中的Selfdestruct功能会为攻击者打开攻击向量(例如使用selfdestruct向一个合约频繁转入token进行攻击,这将大大节省了GAS的费用,虽然很少人这么做),此外,此功能还会降低用户对合约的信心。

ABI编码解码

ABI (Application Binary Interface,应用二进制接口)是与以太坊智能合约交互的标准。数据基于他们的类型编码;并且由于编码后不包含类型信息,解码时需要注明它们的类型。

Solidity中,ABI编码有4个函数:abi.encode, abi.encodePacked, abi.encodeWithSignature, abi.encodeWithSelector。而ABI解码有1个函数:abi.decode,用于解码abi.encode的数据。

ABI编码

uint x = 10;
address addr = 0x7A58c0Be72BE218B41C608b7Fe7C5bB630736C71;
string name = "0xAA";
uint[2] array = [5, 6]; 

abi.encode

//  ABI被设计出来和合约交互,它将每个参数填充为32字节的数据,并拼接在一起。
function encode() public view returns(bytes memory result) {
    result = abi.encode(x, addr, name, array);
}

// 编码的结果为0x000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000007a58c0be72be218b41c608b7fe7c5bb630736c7100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000043078414100000000000000000000000000000000000000000000000000000000,由于abi.encode将每个数据都填充为32字节,中间有很多0。

abi.encodePacked


// 将给定参数根据其所需空间编码。类似abi.encode,但是会把很多0省略。
function encodePacked() public view returns(bytes memory result) {
    result = abi.encodePacked(x, addr, name, array);
}

// 编码的结果为0x000000000000000000000000000000000000000000000000000000000000000a7a58c0be72be218b41c608b7fe7c5bb630736c713078414100000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000006,由于abi.encodePacked对编码进行了压缩,长度比abi.encode短很多。

abi.encodeWithSignature

//和abi.encode功能类似,只不过第一个参数为函数签名,比如"foo(uint256, address)"
function encodeWithSignature() public view returns(bytes memory result) {
    result = abi.encodeWithSignature("foo(uint256,address,string,uint256[2])", x, addr, name, array);
}

// 编码的结果为0xe87082f1000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000007a58c0be72be218b41c608b7fe7c5bb630736c7100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000043078414100000000000000000000000000000000000000000000000000000000,等同于在abi.encode编码结果前加上了4字节的函数选择器1。

abi.encodeWithSelector

//和abi.encodeWithSignature功能类似,只不过第一个参数为函数选择器,为函数签名keccak哈希的前4个字节
function encodeWithSelector() public view returns(bytes memory result) {
    result = abi.encodeWithSelector(bytes4(keccak256("foo(uint256,address,string,uint256[2])")), x, addr, name, array);
}

// 0xe87082f1000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000007a58c0be72be218b41c608b7fe7c5bb630736c7100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000043078414100000000000000000000000000000000000000000000000000000000,与abi.encodeWithSignature结果一样。

ABI解码

abi.decode

// abi.decode用于解码abi.encode生成的二进制编码,将它还原成原本的参数。
function decode(bytes memory data) public pure returns(uint dx, address daddr, string memory dname, uint[2] memory darray) {
    (dx, daddr, dname, darray) = abi.decode(data, (uint, address, string, uint[2]));
}

ABI的使用场景

  • 在合约开发中,ABI常配合call来实现对合约的底层调用。
bytes4 selector = contract.getValue.selector;

bytes memory data = abi.encodeWithSelector(selector, _x);
(bool success, bytes memory returnedData) = address(contract).staticcall(data);
require(success);

return abi.decode(returnedData, (uint256));
  • ethers.js中常用ABI实现合约的导入和函数调用。
const wavePortalContract = new ethers.Contract(contractAddress, contractABI, signer);
/*
* Call the getAllWaves method from your Smart Contract
*/
const waves = await wavePortalContract.getAllWaves();
  • 对不开源合约进行反编译,某些函数无法查到函数签名,可通过ABI进行调用。
    • 0x533ba33a() 是一个反编译后显示的函数,只有函数编码后的结果,并且无法查到函数签名

- 这种情况无法通过构造interface接口或contract来进行调用,这种情况就可以通过ABI函数选择器来调用。

bytes memory data = abi.encodeWithSelector(bytes4(0x533ba33a));

(bool success, bytes memory returnedData) = address(contract).staticcall(data);
require(success);

return abi.decode(returnedData, (uint256));

函数选择器 selector

当我们调用智能合约时,本质上是向同目标合约发送了一段calldata。发送calldata前4个字节是selector(函数选择器)

msg.data

msg.data是solidity中的一个全局变量,值为完整的calldata(调用函数时传入数据)。

// event 返回msg.data
event Log(bytes data);

function mint(address to) external{
    emit Log(msg.data);
}

//当参数为0x2c44b726ADF1963cA47Af88B284C06f30380fC78时,输出的calldata为:
//0x6a6278420000000000000000000000002c44b726adf1963ca47af88b284c06f30380fc78

其中前4个字节为函数选择器selector:0x6a627842

后32个字节为输入参数:0x0000000000000000000000002c44b726adf1963ca47af88b284c06f30380fc78

其实calldata就是告诉合约,我要调用哪个函数,参数是什么。

method id、selector和函数签名

函数签名,为"函数名(逗号分隔的参数类型)"。method id定义为函数签名的Keccak哈希后的前4个字节,当selector与method id相匹配时,即表示调用该函数。

注意,在函数签名中,uint和int要写为uint256和int256

function mintSelector() external pure returns(bytes4 mSelector){
    return bytes4(keccak256("mint(address)"));
}

结果正是0x6a627842

使用selector

我们可以利用selector来调用目标函数。例如我想调用mint函数,我只需要利用abi.encodeWithSelector将mint函数的method id作为selector和参数打包编码,传给call函数:

function callWithSignature() external returns(bool, bytes memory){
    (bool success, bytes memory data) = address(this).call(abi.encodeWithSelector(0x6a627842, "0x2c44b726ADF1963cA47Af88B284C06f30380fC78"));
    return(success, data);
}

Hash

Hash的特性

一个好的哈希函数应该具有以下几个特性:

单向性:从输入的消息到它的哈希的正向运算简单且唯一确定,而反过来非常难,只能靠暴力枚举。

灵敏性:输入的消息改变一点对它的哈希改变很大。

高效性:从输入的消息到哈希的运算高效。

均一性:每个哈希值被取到的概率应该基本相等。

抗碰撞性:

弱抗碰撞性:给定一个消息x,找到另一个消息x’使得hash(x) = hash(x')是困难的。

强抗碰撞性:找到任意x和x',使得hash(x) = hash(x')是困难的。

Hash的应用

  • 生成数据唯一标识。
  • 加密签名。
  • 安全加密。

Keccak256

keccak256函数是solidity中最常用的哈希函数。

hash = keccak256(数据);

// 生成数据唯一标识
function hash(uint _num, string memory _string, address _addr) public pure returns(bytes32){
  return keccak256(abi.encodePacked(_num, _string, _addr));
}

Try-Catch

try-catch

在solidity中,try-catch只能被用于external函数或者创建合约时constructor(被视为external函数)的调用。

try externalContract.f() {
    // call成功的情况下 运行一些代码
} catch {
    // call失败的情况下 运行一些代码
}