跳到主要内容

一文速通 Solidity

· 阅读需 20 分钟

适合有过编程经验的同学阅读

数据类型

布尔

bool public boo = true; // or false

整型

  • uint 无符号整型,有uint8, uint16等, uint8的数据范围是 [0281][0, 2^8 - 1]
  • int 整型,有int8, int16等,int8的数据范围是 [27,271][-2^7, 2^7 - 1]
// uint 默认0
uint8 public u8 = 1;
uint public u256 = 456;
uint public u = 123; // uint 默认是 uint256

// int 默认0
int8 public i8 = type(int).min; // type(int).max
int public i256 = 456;
int public i = -123; // int 定义默认是 int256

地址

使用 address 声明,默认值为 0x0000000000000000000000000000000000000000

address public addr = 0xCA35b7d915458EF540aDe6068dFe2F44E8fa733c;

Map

mapping类型,为key-value结构。支持嵌套

// mapping 定义
mapping(address => uint) public myMap;
myMap[_addr] = 0xCA35b7d915458EF540aDe6068dFe2F44E8fa733c; // map赋值

mapping(address => mapping(uint => bool)) public nested; // 嵌套map

数组

array 数组类型, 支持方法 pushpopgetdelete

// 数组定义
uint[] public arr;
uint[] public arr2 = [1, 2, 3];
uint[10] public myFixedSizeArr; // 固定数组长度

arr.length // 获取数组长度
arr2[1] // 访问数组元素
arr2[1] = 100 // 设置数组元素 [1, 100, 3]
delete arr2[1] // 删除数组元素 [1, 0, 3], 不会真正的删除,只会置空

枚举

enum 枚举类型

// 枚举定义
enum Status {
Pending,
Shipped,
Accepted,
Rejected,
Canceled
}
Status public status;

结构体

struct 结构体

// 结构体定义
struct Todo {
string text;
bool completed;
}
// 声明结构体类型变量
Todo public todo;
Todo[] public todos;

todo = Todo('a', true)
todo = Todo({ text: 'A', completed: true })
todo.completed = false;

接口

使用 interface 声明,声明一个接口时,需要遵循以下要求

  • 不能有函数实现
  • 可以从其他接口继承
  • 所有声明的函数必须声明为 external
  • 不能声明构造函数
  • 不能声明状态变量
interface UniswapV2Factory {
function getPair(address tokenA, address tokenB) external view returns (address pair);
}

interface UniswapV2Pair {
function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
}

函数

函数定义

符合格式: function 函数名(参数类型 参数名) 修饰符 returns(返回值类型) {}

参数名作为约定,其前需带下划线

function add (uint _a, uint _b) returns(uint){
return a + b;
}

返回值

  • 使用关键字 returns 确定返回值
  • 支持多返回值
    • returns支持赋予返回值值名称,此时不需要显示指定return
    • multipleReturns 可以不写关键字 returns
function returnMany() public pure returns(uint, bool, uint) {
return (1, true, 2);
}

// 隐式返回
function assigned() public pure returns(uint x, bool b, uint y) {
x = 10;
b = false;
y = 10;
}

function multipleReturns () {
uint a;
uint b;
uint c;
(a, b, c) = multipleReturns()
}

Modifier

Modifier 是可以在函数调用之前运行的代码, 相当于函数的钩子

// 定义 modifier 要求执行人必须是合约的所有者
// _;表示执行函数体内的代码
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_; // 调用 onlyOwner 的函数的内部代码
}

modifier validAddress(address _addr) {
require(_addr != address(0), "Not valid address");
_;
}

// 执行该函数前会先去执行 onlyOwner 和 validAddress, 如果都通过则才会去执行后续代码
function changeOwner(address _newOwner) public onlyOwner validAddress(_newOwner) {
owner = _newOwner;
}

pure / view

  • view 告诉我们运行这个函数不会更改和保存任何数据, 用于读取链上数据

  • pure 告诉我们这个函数不但不会往区块链写数据,它甚至不从区块链读取数据。

这两种在被从合约外部调用的时候都不花费任何 gas

contract ViewAndPure {
uint public x = 1;

// 没有修改链上的数据x, 仅仅只是读取x的值
function addToX(uint y) public view returns (uint) {
return x + y;
}

// 没有修改链上的数据, 也没有读取链上的值x
function add(uint i, uint j) public pure returns (uint) {
return i + j;
}
}

关键字

Solidity 中的关键字包括了

  • 可见性
  • 常量
  • 不可变量
  • 数据存储位置
  • 可支付

可见性

关键字说明
private只能被合约内部调用
public可以在任何地方调用,不管是内部还是外部
internal能被合约内部调用,还能被继承的合约调用
external只能从合约外部调用

常量

使用 constant 修饰常量,修饰的变量需要在编译期确定值, 链上不会为这个变量分配存储空间, 它会在编译时用具体的值替代, 大写变量名

address public constant MY_ADDRESS = 0x777788889999AaAAbBbbCcccddDdeeeEfFFfCcCc;

不可变量

immutable 修饰的变量是在部署的时候确定变量的值, 它在构造函数中赋值一次之后就不在改变, 这是一个运行时赋值, 可以解除之前 constant 不支持使用运行时状态赋值的限制

// 不可变量
contract Immutable {
// coding convention to uppercase constant variables
address public immutable MY_ADDRESS;
constructor(uint _myUint) {
MY_ADDRESS = msg.sender;
}
}

数据存储位置

storagememorycalldata 修饰的变量

  • storage 变量是状态变量(存储在区块链上), 赋值时是浅拷贝
  • memory 变量在内存中, 不上链, 并且在调用函数时存在, 赋值时是深拷贝
  • calldata 变量在内存中, 不上链, 不能修改, 一般用于函数的参数。相对于 memory, 可以节省 gas
function awardItem(address _to, string memory _tokenURI) public returns(uint) {
uint newItemId = _tokenIds.current();
_mint(_to, newItemId);
_setTokenURI(newItemId, _tokenURI);

_tokenIds.increment();
return newItemId;
}

可支付

函数和地址能声明为 payable, 即可接受 ether 到智能合约

uint levelUpFee = 0.001 ether;
function levelUp(uint _zombieId) external payable {
require(msg.value == levelUpFee);
zombies[_zombieId].level++;
}

调用该函数需要支付 0.001 ether 。在你发送 ether 之后,被存储进以合约的以太坊账户中。 除非你在合约中添加一个提现函数来从合约中提取资产。

function withdraw() external onlyOwner {
// 获取合约账户的余额
uint balance = address(this).balance;
if (balance > 0) {
// 转账到 owner 账户
payable(owner()).transfer(balance);
emit Withdraw(owner(), balance);
}
}

转移合约中的资产有以下三种方式

  • payable(_to).transfer(balance);
  • bool sent = payable(_to).send(balance);
  • (bool sent, bytes memory data) = _to.call{ value: balance }('');

智能合约基础

创建智能合约

创建智能合约之前需要声明 SPDX 协议

软件包数据交换(SPDX) 规范定义了一个用于交流软件组件信息的开放标准,具体查看 licenses

其实还要声明 solidity 的版本

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;

contract X {}

构造函数

只在合约部署的时候调用一次,之后便不再调用

contract X {
string public name;
address public owner;
constructor(string memory _name) {
name = _name;
owner = msg.sender;
}
}

变量

  • 状态变量: 存储在链上的变量,所有合约内函数都可以访问,gas消耗高,声明在合约内、函数外
  • 局部变量: 局部变量是仅在函数执行过程中有效的变量,函数退出后,变量无效。局部变量的数据存储在内存里,不上链,gas
  • 全局变量: 全局变量是全局范围工作的变量,都是solidity预留关键字。他们可以在函数内不声明直接使用。查看
    • msg.sender
    • block.number
    • msg.data

继承

  • 支持多继承
  • 重写父级函数
    • 父合约中的方法加上了 virtual 则表示子合约可以重写
    • 子合约重写父合约中的方法时需加上 override 关键字
    • 多继承时,某个方法同时存在于多个父合约
  • 调用父级函数:super.func() 或者 父合约名.func()
  • 子合约中不能通过声明相同的变量来覆盖父级合约中的变量
// 创建合约X
contract X {
string public name;
constructor(string memory _name) {
name = _name;
}

// 定义可重写方法
function getName() public virtual view returns(string memory){
return name;
}
}

// 创建合约Y
contract Y {
string public text;

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

// 多继承,并通过参数形式调用父级合约的构造函数
contract B is X("Input to X"), Y("Input to Y") {

// 重写父级函数
function getName() public override view returns(string memory){
// 调用父级函数
return super.getName();
}
}

// 多继承, 子合约在构造函数调用父级构造函数,可以指定调用顺序,为 X, Y, C
contract C is X, Y {
constructor(string memory _name, string memory _text) X(_name) Y(_text) {}
}

特性

合约间交互

合约允许与链上的其他合约进行交互

假设存在链上合约 LuckyNumber

contract LuckyNumber {
mapping(address => uint) numbers;

function setNum(uint _num) public {
numbers[msg.sender] = _num;
}

function getNum(address _myAddress) public view returns (uint) {
return numbers[_myAddress];
}
}

另一个合约希望访问 LuckyNumber 中的数据,需要先定义其 Interface

interface LuckyNumberInterface {
function getNum(address _myAddress) public view returns (uint);
}

contract MyContract {
// LuckyNumber 的合约地址
address NumberInterfaceAddress = 0xab38...;

// numberContract 指向一个合约对象
LuckyNumberInterface numberContract = NumberInterface(NumberInterfaceAddress);

function someFunction() public {
// 访问合约方法
uint num = numberContract.getNum(msg.sender);
}
}

发送主币

三种方式

  • transfer
  • send
  • call
contract SendEther {
contract() payable {}

receive() external payable {}

function send(address payable _to) external payable {
// _to.transfer(123);
// bool sended = _to.send(123)
// (bool sended, ) = _to.call{value: 123}("")
}
}

接收主币

receive

  • 接收主币时调用,一个合约最多只能存在一个该函数。声明方式与一般函数不一样,不需要 function 关键字
  • receive() 函数不能有任何的参数,不能返回任何值,必须包含 externalpayable
  • 需要注意的是用户往该合约发送主币时,gas 会被限制在2300,如果 receive 执行过多的逻辑,可能会出现 Out of Gas 错误
contract ReceiveEther {
event ReceivedEvent(address sender, uint value);
receive () external payable {
emit ReceivedEvent(msg.sender, msg.value);
}
}

fallback

  • 调用不存在的函数
  • 主币被直接发送到合约,但 receive() 不存在或 msg.data 不为空
contract ReceiveEther {
event FallbackedEvent(address sender, uint value, bytes data);
fallback () external payable {
emit FallbackedEvent(msg.sender, msg.value, msg.data);
}
}

receivefallback 的执行选择

  • 存在 msg.data 调用 fallback
  • 不存在 msg.data
    • 存在 receive 则调用 receive
    • 不存在 reveive 则调用 fallback

reveivefallback 都不存在, 则会报错

异常处理

try...catch... / throw

function tryCatchExternalCall(uint _i) public {
try foo.myFunc(_i) returns (string memory result) {
// ...
} catch {
// ...
}
}

require / revert / assert

contract A {
function testRequire(uint _i) public pure {
require(_i > 10, '_i error'); // 当 _i <= 10报错
}
function testRevert(uint _i) public pure {
if (_i > 10) {
revert('i need to be greater than 10'); // 不满足条件主动触发错误
}
}
function testAssert(uint _i) public pure {
assert(_i == 100); // 不满足assert内部条件的报错
}
}

自定义错误

contract A {
// 定义错误
error MyError(address caller, uint i);

function testCustomeError(uint _i) public view {
if (_i > 10) {
revert MyError(msg.sender, _i)
}
}
}

模块化

import

使用 import 导入合约,并使用导入的合约的内容。 import 可以使本地合约路径或远程地址

// A.sol
contract A {}

// B.sol
import "./A.sol"; // 导入合约A
contract B is A {}

library

库类似于合约,但你不能声明任何状态变量,也不能发送以太币。如果所有库函数都是 internal 的,则将库嵌入到合约中。

使用库合约需要使用 using for , 如下所示

// safemath.sol
library SafeMath {
function add(uint x, uint y) internal pure returns (uint) {
uint z = x + y;
require(z >= x, "uint overflow");

return z;
}
}

// TestSafeMath.sol
import "./safemath.sol";
contract TestSafeMath {
// 使用 SafeMath, 则 uint 会拥有 SafeMath 所有方法
using SafeMath for uint;

uint public MAX_UINT = 2**256 - 1;

function testAdd(uint x, uint y) public pure returns (uint) {
return x.add(y);
}

function testSquareRoot(uint x) public pure returns (uint) {
return Math.sqrt(x);
}
}

unchecked

0.8.0 版本开始,算术运算有两个计算模式:

  • unchecked模式,即在发生溢出的情况下会进行“截断”,不会触发失败异常,从而得靠引入额外的检查库来解决这个问题(如 OpenZeppelin 中的 SafeMath 库)
  • checked模式,默认情况下,会进行溢出检查,如果结果溢出,会出现失败异常回退。
contract TestContract {
function checkedTest() external pure returns(uint256) {
// 溢出 会触发异常
uint256 x = 0;
x--;
return x;
}
function uncheckedTest() external pure returns(uint256) {
// 不会报错
uint256 x = 0;
unchecked { x--; }
return x;
}
}

create / create2

用于在合约内创建合约。

create 创建合约是通过new构造合约实例,并传入构造参数

// value 表示可以payable的构造函数转入的eth数量
Contract con = new Contract{value: _value}(params);

address(con); // 合约的地址

create合约地址是通过交易发起者sender的地址以及交易序号nonce 来计算确定的。sendernonce 进行 RLP 编码,然后用 Keccak-256 进行 hash 计算(伪码)

address = keccak256(rlp([sender, nonce]));

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

create2 创建合约也是通过new 构造合约实例,不同的是需要一个 salt 参数

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

create2创建的合约地址由 4 个部分决定:

  • 0xFF:常数
  • 交易发起者sender的地址
  • salt:给定的数值
  • 待部署合约的字节码(bytecode
address = keccak256(0xFF + sender + salt + bytecode)

因此可以事先计算出create2合约地址

address(uint(keccak256(abi.encodePacked(
hex'ff',
address(this),
salt,
keccak256(type(Contract).creationCode)
))));

call

用来与其他合约进行交互,返回值为 (bool success, bytes memory data) , success 表示调用是否成功,data 表示调用的函数的返回值

使用方式如下:

contractAddress.call(abi.encodeWithSignature("函数名(...参数类型)", ...params));

如果调用的合约方法是可交易的,则可以指定发送的 eth 数量

contractAddress.call{ value: _value, gas: _gas }(abi.encodeWithSignature(...))

例如

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

contract Receiver {
uint256 private _value = 0;
event Received(address caller, uint amount, string message);
fallback() external payable {
emit Received(msg.sender, msg.value, "Fallback was called");
}
// 设置 _value 的值
function store(uint256 newValue) public payable returns(uint){
_value = newValue;
emit Received(msg.sender, msg.value);
return newValue + 1;
}
// 读取 _value 的值
function retrieve() public view returns (uint256) {
return _value;
}
}

contract Caller {
event Response(bool success, bytes data);

function testCall(address payable _addr) public payable {
// 调用 Receiver 合约的 store 函数,修改 Receiver 中的变量 _value 的值为88
(bool success, bytes memory data) = _addr.call{value: msg.value}(
abi.encodeWithSignature("store(uint256)", 88)
);
emit Response(success, data);
}
}

delegatecall

委托调用,顾名思义就是委托别人去调用,和 call 的区别见下图

通过 delegatecall 调用合约 B 函数时,由于此时上下文是处于合约 A 的,所以函数执行所影响的状态是合约 A 的。

调用方式和 call 类似

contractAddress.delegatecall(abi.encodeWithSignature("函数名(...参数类型)", ...params));

示例:

contract Receiver {
uint256 private _value = 0;

function store(uint256 newValue) public {
_value = newValue;
}

function retrieve() public view returns (uint256) {
return _value;
}
}

contract Caller {
uint256 public _value = 0;

function testDelegateCall(address payable _addr) public payable {
// 通过 delegatecall 调用 Receiver 合约中的 store方法时
// 由于 Receiver 合约此时所处的上下文是在 Caller 合约中。所以对 _value 的赋值是 Caller 中的。
// 导致的最终结果就是 Receiver 中 _value 值仍为0, Caller 中 _value 值为88
(bool success, ) = _addr.delegatecall(
abi.encodeWithSignature("store(uint256)", 88)
);
}
}

selector

当调用合约中的函数时会传入一段 calldata,其值说明了要调用的函数名以及传入的参数。例如调用 store(10) 时,其 calldata 如下所示

0x6057361d000000000000000000000000000000000000000000000000000000000000000a

前 4 个字节 0x6057361d 为函数选择器,也叫 selector,由 keccak256 计算得到

bytes4(keccak256("store(uint256)")); // 0x6057361d

selector 后面的值为函数参数。

当通过 call 调用时就可以使用 selector 指定方法

(bool success, bytes memory data) = _addr.call{value: msg.value}(
abi.encodeWithSelector(0x6057361d, 88)
);