Smart contract study notes of bilibili blogger "Dongfeng Grass Green".

solidity智能合约

0x00 智能合约入门

基础知识

​ 智能合约的机制:节点一侧是transaction,一侧是EVM;transaction被确认之后,会触发EVM的执行。

11

​ 简单的智能合约代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
pragma solidity 0.8.12;
contract NumberStore{
uint public x;
function setX(uint px) public {
x = px;
}
function getX public view returns(uint) {
return x;
}
function add(uint a, uint b) private pure returns(uint) {
return a + b;
}
}

开发环境:hardhat(合约打包、模拟器、测试框架,可以开发部署完整项目),remix(solidity语言的开发、测试)。

变量可见性:

1
2
3
4
public # 完全可见
external # 完全可见。与public不同的地方:(1)同一合约的函数调用一个本合约的external函数时,要this关键字来调,这意味着要用call方式执行,要产生新的上下文,耗费资源。
private # 对本合约可见,其他不可见
internal # 对本合约与继承子合约可见,变量默认情况是internal,而函数默认为public

​ 以太坊地址为20字节(160位)。地址分为合约地址(指向一个合约)与钱包地址(代表一个account)。

函数的交易属性:

1
2
3
view # 合约状态读操作,这样的话就没有必要产生一个交易
pure # 既不是读操作也不是写操作,与合约关系不大,只跟输入的参数有关(纯函数)。为什么有pure,是因为有一个信任的关系,比如我们想要计算某个资产,我们更希望交给区块链上的某一个节点来算,而不是自己偷着算。(然而,如果一个pure函数被另一个产生交易的函数内部调用,那么它将花费gas,因为一个交易的gas成本取决于完成它时执行的EVM操作码的数量)
transactional # 默认是这样的,但是没有具体的关键字,代表这个函数背后隐含着一个交易的产生(写操作)

构造函数:

1
2
3
4
5
6
7
8
pragma solidity 0.8.12;
contract NumberStore{
uint public x;
// 构造函数
constructor(uint _x){
x = _x;
}
}

函数修饰器:定义什么样的实体能够调用什么样的函数(访问控制)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pragma solidity 0.8.12;
contract NumberStore{
uint public x;
constructor(uint _x){
x = _x;
}
// 修饰器
modifier notTooLarge(uint _x){
require(_x < 10000, "too large"); // 如果_x >= 10000,就会抛出too large异常。
// 或者这么写
/*
if(_x >= 10000)
revert("too large");
*/
_; //执行所修饰的函数体
}
function setX(uint _x) public notTooLarge(_x){
x = _x;
}
}

log日志:一旦对区块链进行写操作,就需要将所有的节点状态改变,有必要记录一下。

1
2
3
4
5
6
7
8
9
10
11
12
pragma solidity 0.8.12;
contract NumberStore{
// log日志定义
event Xchanged(uint oldv, uint newv);
uint public x;
function setX(uint px) public {
x = px;
//log日志使用
emit Xchanged(x, px);
}

}

数据类型:

1
type(uint8).min //取数据类型的最小值
  1. 以太坊虚拟机是256位的机器。

  2. 低版本以太坊取模,高版本直接抛出异常。举例uint8 x = 255; x++; x --> 0,而高版本直接抛出异常。

  3. uint是uint256的别名。
  4. 可以定义bytes1bytes32bytes32 arr; byte b = arr[1];.length可以定义数组长度。

Address(重点):

​ Address分为合约账号地址与外部账号地址(某个钱包 EOA:external owned account),160位。地址中包括可支付的地址(payable address:可以给这个地址转钱),EOA都是可以payable address,而有的合约账号地址不能收钱。其中可以转账(send),查询余额(balance)。

合约类型:

​ 每一个contract都有自己的类型,可以转化为address,例如:MyContract c; address addr = address(c);。可以类比为C++中的类。在区块链上部署的合约可以使用new来创建一个新的合约。

枚举:可以看作是一个8位整型数,0-255,可以遍历。举例说明:

1
2
3
4
5
6
7
8
9
10
11
12
pragma solidity 0.8.12;

contract test{
enum Action{left, right, straight, back}
Action choice;
function setChoice(uint x) public{
choice = Action(x);
}
function getChoice() public view returns(uint){
return uint(choice);
}
}

映射mapping(重点):键值对(key,value)key不能是合约、映射,value可以,mapping无法遍历,mapping无法当参数与返回值,也不能在函数体内定义,只能在contract的成员变量存在。声明为public的映射会自动创建一个getter函数,这样的话就像字典一样访问了。举例:

1
2
3
4
5
6
7
8
pragma solidity 0.8.12;

contract test{
mapping(address => uint) public balances;
function update(uint newb) public{
balances[msg.sender] = newb;
}
}

函数的上下文变量:外部账户(个人用户,钱包)调用函数时,函数的背后都对应着交易transaction。内部账户(合约)调用另一个合约函数时,也是用一个类似于transaction的数据结构去调用的,这个结构叫做message。

1
2
3
msg.sender //函数的调用者,address
msg.data
msg.value // eth,以太币

下面我们写一个合约,管理所有的账户,且不同的账户之间可以互相转钱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pragma solidity 0.8.12;

contract sol{
mapping(address=>uint) public balance;

constructor(uint x){
balance[msg.sender] = x;
}

function transfer(address to, uint amount) {
address from = msg.sender;
if(balance[from] < amount){
revert("not enough!");
}
balance[to] += amount;
balance[from] -= amount;
}
}

智能合约如何与前端交互:web3j。web3j是一个开源Java库,旨在为以太坊开发人员提供一个简单的、类型安全的接口。它抽象出了与以太坊区块链交互的底层细节,并且使用简单的Java对象,提供了对智能合约的编程接口。

Metamask是一个浏览器以太坊钱包,它的运行机制如下:

  1. 安装:MetaMask 作为浏览器扩展安装在 Chrome上。用户创建一个新钱包并设置密码来加密和保护他们的私钥。
  2. 私钥管理:MetaMask 生成一个私钥,存储在用户浏览器本地。私钥用于签署交易,用户对其拥有完全控制权,允许他们访问自己的资金并与 dApp 进行交互。
  3. 与 dApp 交互:安装 MetaMask 后,用户可以访问任何构建在以太坊区块链上的 dApp 并与之交互。dApp 将向 MetaMask 发送请求以访问用户的以太坊帐户并代表他们签署交易。
  4. 交易签名:当 dApp 想要执行交易时,它会将详细信息发送给 MetaMask,然后 MetaMask 会提示用户审核并批准交易。用户可以选择签署或拒绝交易,如果批准,MetaMask 使用私钥签署交易。
  5. 广播交易:交易签署后,MetaMask 将其发送到以太坊网络进行处理。然后网络将交易添加到一个区块中,它成为永久以太坊区块链分类账的一部分。

​ 总之,MetaMask 充当用户和以太坊区块链之间的桥梁,允许用户与 dApp 交互并安全地管理他们的私钥和资金。

dApp是什么?

​ 去中心化应用程序 (dApp) 是在去中心化网络(例如区块链)上运行的软件应用程序,而不是单个集中式服务器。dApps 被设计成自治的,这意味着它们可以在没有任何中央权威或中介的情况下运行。

​ dApp 通常由前端用户界面和后端区块链网络组成,并根据智能合约中编码的规则运行。dApp 的关键特征是其底层代码和数据存储在去中心化网络中,每个人都可以访问,从而提供透明度和不变性。

​ dApp 可用于多种用途,例如数字钱包、交易所、市场、预测市场等。dApp 的去中心化特性确保它们是安全的、无需信任的和防篡改的,使其成为需要高度安全性和透明度的用例的理想选择。

一些基于以太坊区块链构建的流行 dApp 包括 CryptoKitties、Uniswap 和 MakerDAO

Time:2023-02-10


通过web3j如何访问智能合约?

补充:

  1. hfs.exe是将html文件以http的方式展现给我们的一个文件服务器。
  2. external关键字:external 关键字在 Solidity 中用于声明一个外部函数,这意味着该函数可以从外部调用,但不能在合约内部调用。外部函数在以太坊网络上充当接口,允许其他合约或外部实体与该合约进行通信。外部函数的签名与参数类型必须完全匹配,否则将无法调用该函数。该关键字的一个常见用法是定义与其他合约的接口,以允许在合约之间进行通信。例如,一个合约可以声明一个外部函数,然后其他合约可以调用该函数,以执行某些功能。
  3. web3.eth.getCoinbase()是一个Web3.js函数,它返回当前Ethereum节点的默认挖矿地址,也称为“Coinbase”地址。这个地址是用来接收新挖出的以太坊块中的以太币奖励的。如果你在运行自己的节点,并且已经开始了挖矿,那么你可以使用这个函数来确定你的Coinbase地址。

​ 一个智能合约owner.sol如下:

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
// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.7.0 <0.9.0;

import "hardhat/console.sol"; // 先不管

contract Owner {

address private owner;

// owner发生变化的时候的event
event OwnerSet(address indexed oldOwner, address indexed newOwner);

// 修饰器,查看调用者是否是owner
modifier isOwner() {
require(msg.sender == owner, "Caller is not owner");
_;
}

// 构造函数,第一次部署这个合约的时候,owner=msg.sender
constructor() {
console.log("Owner contract deployed by:", msg.sender);
owner = msg.sender;
emit OwnerSet(address(0), owner); // event 日志
}

// 只有合约的所有者才能changeOwner
function changeOwner(address newOwner) public isOwner {
emit OwnerSet(owner, newOwner);
owner = newOwner;
}

// external先不用管,view代表读操作
function getOwner() external view returns (address) {
return owner;
}
}

​ 编译完成后,在Owner_metadata.json中,会找到abi(application binary interface:应用二进制接口)的相关信息,这是web3j调用合约的接口描述,如下所示:

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
"abi": [
{
"inputs": [],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "oldOwner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "OwnerSet",
"type": "event"
},
{
"inputs": [
{
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "changeOwner",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "getOwner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
}
]

​ 将其放到owner.js中,最后进行试验,详见:

https://github.com/WD-2711/smart_contract_files/tree/main/web3j%E8%AE%BF%E9%97%AE

0x01 智能合约基础

补充:

  1. ERC20:最基础的智能合约规范。
  2. ICO(Initial Coin Offering)是一种融资方式,用于吸引投资者投资区块链项目,或将传统证券转换为加密货币。在ICO中,项目团队发行新的加密货币,并将其作为对项目的投资回报。投资者通过投资以太坊或比特币等已有加密货币,来获得新发行的加密货币。
  3. 智能合约管理利益各方的利益关系。

合约是如何部署到区块链上的?

​ 区块链上的合约通常使用智能合约语言编写,如 Solidity(用于以太坊)、Chaincode(用于Hyperledger Fabric)等。为了部署合约到区块链上,你需要执行以下步骤:

  1. 编写智能合约代码:使用智能合约语言编写代码,确保它符合区块链平台的规则。
  2. 编译代码:使用合适的编译器将智能合约代码编译成能在区块链上运行的代码。
  3. 部署合约:使用适当的工具(如 Remix)或命令行界面将合约部署到区块链。
  4. 等待确认:等待合约部署交易在区块链上得到确认。
  5. 调用合约:通过客户端或DApp(去中心化应用程序)调用合约并执行相应的操作。

​ 注意:部署合约需要支付费用,因为区块链网络上的节点需要执行合约代码。费用通常以加密货币(如以太币)的形式支付。

智能合约部署到区块链上的底层原理是什么?

​ 智能合约部署到区块链上的底层原理包括以下步骤:

  1. 创建交易:将智能合约代码打包创建一笔交易,该交易将在区块链上的节点中被执行。
  2. 签名交易:使用密钥对交易进行签名,以验证交易的发送者身份。
  3. 广播交易:将签名后的交易广播到区块链网络的其他节点上。
  4. 执行交易:所有节点对该交易进行验证,如果验证通过,则该交易将在区块链上的虚拟机(如Ethereum虚拟机)上执行。
  5. 存储合约:在交易被执行后,智能合约代码将被存储在区块链上。
  6. 调用合约:任何人都可以通过发送交易来调用该合约,以执行合约中定义的操作。

​ 总体来说,智能合约部署到区块链上的原理是通过创建一笔交易,将智能合约代码打包到该交易中,并通过区块链上的节点对该交易进行验证和执行,从而将智能合约代码存储到区块链上并可供任何人调用。

如何将智能合约代码打包成一笔交易?

​ 将智能合约代码打包成一笔交易需要使用一个特定的数据结构,这通常被称为交易的输入数据。这个数据结构通常包含以下几个部分:

  1. 合约代码:该代码将被执行,并存储在区块链上。
  2. 调用数据:指定该交易如何调用智能合约。
  3. 费用:指定用于执行该交易的代币数量。
  4. 签名:证明交易发送者是否拥有指定的代币数量,并同意用这些代币支付交易的费用。

​ 在部署智能合约时,通常会指定一些特定的调用数据,如调用合约的初始化方法,以创建智能合约。

​ 最后,打包成的交易可以在区块链网络上广播,以便在整个网络中的节点都可以对其进行验证并执行。

交易是如何在区块链上的虚拟机上执行的?

​ 在区块链上,每个节点都运行着一个虚拟机,该虚拟机用于执行智能合约代码和交易。当一笔交易被广播到区块链网络上时,每个节点都会对其进行验证,以确保交易的费用足够支付执行代码的成本,并且交易签名是有效的。

​ 如果交易被验证,虚拟机将按照交易的调用数据执行合约代码。在执行过程中,虚拟机可能会对区块链的状态进行更改,例如,更新智能合约的内部状态,在资产账户之间转移代币,或创建新的智能合约。

​ 在执行完成后,虚拟机将生成一个执行报告,该报告描述了交易的执行结果。如果交易执行成功,执行报告将被添加到区块链的下一个区块中,并且将在整个网络中永久存储。如果交易执行失败,执行报告将不会被添加到区块链中,并且交易将被视为无效。

tether USD是什么?

​ Tether USD (USDT)是一种以美元为基础资产的代币,是一种加密数字资产。它是由Tether公司开发和管理的,旨在模拟美元,并且其价值与美元兑换率相同。Tether USD的目的是提供一种可靠的数字资产,其价值相对稳定,并且可以与美元相互转换。

​ 给定一个合约NumberStorage.sol:

1. NumberStorage.sol代码
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

// SPDX-License-Identifier: NONE

pragma solidity 0.8.12;

contract NumberStorage{
// 下列是ERC20规范所要求的属性(name, symbol, decimal, balanceOf)
string public name = "xcl";
string public symbol = "$";
uint public decimal = 4; // 基本货币单位,例如balance=20000,那么在metamask中就显示2$
mapping(address=>uint) public balanceOf; // 定义余额

// 构造函数,在其中铸币(mint),这些部分可以根据需求来自己写,分发给哪些人?
constructor(uint x){
balanceOf[msg.sender] = x;
}

// 转账事件
event TransferEvent(uint oldv, uint newv);

// 修饰器
modifier notTooLarge(uint _x){
require(_x < 10000, "too large");
_;
}

// 转账函数,一般是固定的
function transfer(address to, uint amount) public {
address from = msg.sender;
if(balanceOf[from] < amount){
revert("not enough!");
}
balanceOf[to] += amount;
balanceOf[from] -= amount;
}
}

​ 在remix上部署,并在metamask上添加资产,最终得到:

image-20230211123159722

​ 非常简陋,相当于2017年的时候的ICO。

一般合约间的函数调用

  1. 如果被调用合约在另外一个源文件中,可以通过 import 引入其他合约的函数。
  2. 调用者必须持有被调用合约的地址。
  3. 利用地址重载被调用的合约,并调用此合约的函数。

​ 举例说明:

1
2
3
4
5
6
7
8
9
10
11
// Callee.sol
// SPDX-License-Identifier: NONE

pragma solidity 0.8.12;

contract Callee{
uint public x;
function setX(uint _x) external {
x = _x;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Caller.sol
// SPDX-License-Identifier: NONE

pragma solidity 0.8.12;

import "./Callee.sol";
contract Caller{
address public calleeAddr;
constructor(address ca){
calleeAddr = ca;
}
function setCalleeX(uint _x) external{
Callee callee = Callee(calleeAddr); // 调用了callee.sol中的setX函数
callee.setX(_x);
}
}

利用接口的函数调用(重点)

  1. 调用者有被调用合约的地址。
  2. 调用者自己定义一个接口,其中函数的signature(函数的定义部分)与被调用合约的相应函数一致(不用import)。
  3. 将合约地址重载为自己定义的接口,并调用其函数。

​ 举例说明,callee.sol与之前一致,不再重复。重点是Caller.sol,其实感觉挺离谱的,重点应该是external,还有就是传递的合约地址,这样就可以解耦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Caller.sol
// SPDX-License-Identifier: NONE

pragma solidity 0.8.12;

interface MyCallee{
function setX(uint _x) external; // 只需要setX函数的定义即可,不需要callee其他的函数。
// 需要注意的是,首先先部署callee.sol,之后我们就可以直接将callee.sol的代码删除,之后再部署caller.sol。
}

contract Caller{
address public calleeAddr;
constructor(address ca){
calleeAddr = ca;
}
function setCalleeX(uint _x) external{
MyCallee callee = MyCallee(calleeAddr);
callee.setX(_x);
}
}

如何调用tether goerli的源码来写我们的合约

​ 首先复制合约地址(https://goerli.etherscan.io/token/0x509ee0d083ddf8ac028f2a56731412edd63223b9)。

​ 之后写如下代码:

1
2
3
4
5
6
7
8
9
// call_tether.sol
// SPDX-License-Identifier: NONE
// 复制的合约地址:0x509Ee0d083DdF8AC028f2a56731412edD63223B9
pragma solidity 0.8.12;
interface MyTether{
function name() external view returns(string memory);
function symbol() external view returns(string memory);
function decimals() external view returns(uint);
}

image-20230211174620111

​ 重载之后,可以发现:

image-20230211175212792

​ 再来写一段代码,这是自己部署了一个合约来查询tether goerli的名字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// call_tether.sol
// SPDX-License-Identifier: NONE
// 复制的合约地址:0x509Ee0d083DdF8AC028f2a56731412edD63223B9
pragma solidity 0.8.12;
interface MyTether{
function name() external view returns(string memory);
function symbol() external view returns(string memory);
function decimals() external view returns(uint);
}

contract MyContract{
address public addr;
constructor(address a){
addr = a;
}
function getTetherName() external view returns(string memory){
MyTether th = MyTether(addr);
return th.name();
}
}

函数调用时的上下文变量(智能合约基本原理)

3

​ 上述图不做多说,心里要理解。可能总结的不太对,不对的话后面再说。代码举例如下:

1
2
3
4
5
6
7
8
9
10
11
12
// call_chain.sol
// SPDX-License-Identifier: NONE
pragma solidity 0.8.12;

contract A{
function a1() public view returns(address){
return a2();
}
function a2() public view returns(address){
return msg.sender;
}
}

image-20230211184746514

​ 这对应EOA---a1()---a2()部分,由结果可以看出,a1()与a2()对应的是一种message。

​ 再来看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// call_chain_A.sol
// SPDX-License-Identifier: NONE
pragma solidity 0.8.12;

interface B_reload{
function b1() external view returns(address);
}

contract A{
address public addr;
constructor(address a){
addr = a;
}
function a1() public view returns(address){
B_reload re = B_reload(addr);
return re.b1();
}
}
1
2
3
4
5
6
7
8
9
// call_chain_B.sol
// SPDX-License-Identifier: NONE
pragma solidity 0.8.12;

contract B{
function b1() external view returns(address){
return msg.sender;
}
}

​ 先部署call_chain_B.sol,再部署call_chain_A.sol。结果如下:

image-20230211195317325

​ 这对应着EOA---a1---b1的环节。

合约函数的动态调用方法(Call)

​ 之前无论是 import 还是接口重载调用,都是静态调用。

​ 静态调用是在编译时预先确定的调用,不会改变合约状态。因为它们不修改合约状态,所以静态调用可以在不需要任何费用的情况下执行。动态调用是在运行时确定的调用,可以改变合约状态动态调用需要消耗gas,因为它们需要在区块链上进行状态更改。在 Solidity 中,使用关键字 “view” 来声明静态调用,而动态调用则不需要声明。

​ 下面是一个静态调用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
contract A {
function multiply(uint x, uint y) public view returns (uint) {
return x * y;
}
}

contract B {
function callMultiply(address _addressA) public returns (uint) {
A a = A(_addressA);
uint result = a.multiply(2, 3);
return result;
}
}

​ 在合约 B 中,我们通过调用合约 A 的 multiply 函数进行静态调用。因为 multiply 函数是用 view 关键字声明的,所以它是一个静态调用。

​ 再给一个动态调用的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
contract A {
uint public value;
function setValue(uint _value) public {
value = _value;
}
}

contract B {
function callSetValue(address _addressA, uint _value) public {
A a = A(_addressA);
a.setValue(_value);
}
}

​ 在合约 B 中,我们通过调用合约 A 的 setValue 函数进行动态调用。由于该函数改变了合约 A 的状态,因此它是一个动态调用。在执行此调用时,需要消耗 gas,因为区块链上的状态需要更改。

Time:2023-02-11


call的调用格式:<address>.call(calldata)call是address的方法,返回值为(bool success, bytes data)

calldata的结构:calldata的前四个字节指向合约的哪个函数,也叫做selector,剩下的数据就是参数编码后的数据。其中seletor=bytes4(keccak256(<sig>)),其中<sig>为函数字符串,keccak256可以看作哈希。总的来说,代码是这样写的:calldata=abi.encodeWithSignature(sig, ps),sig为函数字符串。

​ 上述静态动态调用是chatGPT给出的结果,然而老师讲的却不是这样。下面给出一个老师讲的静态调用的例子

1
2
3
4
5
6
7
//callee.sol
contract Callee{
uint x;
function setX(uint px) public {
x = px;
}
}
1
2
3
4
5
6
7
import "callee.sol"
contract Caller{
Callee callee;
function SetCalleeX(){
callee.setX(12); // 编译的时候检查
}
}

​ 再给出一个动态调用的例子:(可以绕过solidity的类型检查 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
contract Callee{
uint x;
function setX(uint px) public {
x = px;
}
}
contract Caller{
address callee;
constructor(address ca){
callee = ca;
}
function SetCalleeX(){
bytes memory calldata = abi.encodeWithSignature("setX(uint256)", 12); // 不能使用别名
(bool suc, bytes memory data) = callee.call(calldata);
if (!suc){
revert("error");
}
}
}

注1:memory使用场景

memory是一种数据存储位置。它表示在执行函数时将变量存储在内存中而不是存储在合约的存储器中。以下是memory关键字的使用示例:

  • 字符串操作。字符串是一个动态的数据类型,它的长度不固定。因此,将它们存储在内存中。
  • 数组操作。与字符串操作类似。

注2:external与public的区别

public 修饰的函数或状态变量可以被合约内部和外部的任何部分访问,而 external 修饰的函数或状态变量只能被其他合约访问,而不能被合约内部访问。

注3:remix中的call函数调用

​ 如下图所示,我们可以在setX中来变化X的值,也可以复制calldata,放到下面的大红框中,来进行X的设置。

image-20230218105130790

fallback函数(备胎函数)

​ 和constructor()一样,都是特殊函数。当我们调用其他合约的某个函数,且这个函数不存在的时候,就会调用fallback函数。其在转账功能和proxy模式中有重要作用,后面再详细说。

​ 举例说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Callee.sol
// SPDX-License-Identifier: NONE

pragma solidity 0.8.12;

contract Callee{
uint public x;
uint public y;
function setX(uint _x) public {
x = _x;
}
// fallback在被调用者中定义
fallback() external{
y = 900;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Caller.sol
// SPDX-License-Identifier: NONE

pragma solidity 0.8.12;

contract Caller{
address public calleeAddr;
constructor(address ca){
calleeAddr = ca;
}
function setCalleeX() public{
// 调用了一个不存在的函数setY,触发fallback函数
bytes memory cd = abi.encodeWithSignature("setY(uint256)", 100);
(bool suc, bytes memory data) = calleeAddr.call(cd);
if(!suc){
revert("error");
}
}
}

gas与转账

  • 合约之中可以存钱,例如Multisig钱包。
  • 以太币货币单位ETH。

image-20230222223833629

  • gas与gas price。gas是油,gas price是油费,固定的合约函数执行时,gas没有变化,变化的是gas的价格。交易的发起者可以设置gaslimit,表示发起者最多想 要消耗多少gas。当交易失败时(使用revert或者require),已经用了的gas不退。

  • 转账是与函数调用一块发生的,转账就是函数调用。

  • 合约是可以有余额(有钱)的。当调用者调用合约的函数时,例如contractA.foo{options}(),可以添加一些选项options,例如{gaslimit, value(转给合约多少钱)},如果不加value,相当于调用者不会给合约转钱。而被调用者(某个合约)可以添加payable标识符,以标识被调用者是否有能力收钱。

 我们给出一个图,回顾一下之前的东西:

image-20230307224400880

 解释一下上图,其中msg.data是一个完整的调用数据(calldata)。它包含了调用者发送给合约的函数签名和参数。它可以用来实现低级的合约调用,例如使用delegatecall或call。如果calldata是空的,那么直接执行fallback函数(但是执行哪个合约的fallback呢?执行被调用合约的fallback),如果不是空,就解析一下,得到函数名fname,如果函数名不存在,那么就执行被调用合约的fallback

 在上述逻辑的基础上,如果没有加入选项value,即调用者没带钱,那么上述逻辑正常执行。如果加入了选项value,调用者带了钱,那么就要看被调用的合约是否是payable,即是否支持收钱。(带钱反而还麻烦,离谱)

 如果我们就想转钱,不想让被调用合约执行某些函数,且被调用的合约中的fallback函数是payable的,那么我们直接contractA.foo{value:<value>}(""),即calldata直接是一个空串,这样就行了。

下面给出一个转账示例,是EOA转给SendEther合约,然后转给ReceiveEther合约:

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
// SPDX-License-Identifier: NONE

pragma solidity ^0.8.13;

// 收款方
contract ReceiveEther {

event Fallback(bytes cdata, uint value, uint gas);
event Foo(bytes cdata, uint value, uint gas);

fallback() external payable {
emit Fallback(msg.data, msg.value, gasleft()); // msg.data为空,或者解析不出一个正常的函数时使用fallback
}

function getBalance() public view returns (uint) { // 此合约有一个balance的属性,存放着当前的余额(收的款value的总和)
return address(this).balance;
}

function foo() public payable {
emit Foo(msg.data, msg.value, gasleft()); // 记录 event
}
}

// 转账方
contract SendEther {

function getBalance() public view returns (uint) {
return address(this).balance;
}

// 收钱,里面不用写代码,是外部EOA给SendEther转的钱
function recv() public payable {

}

// 转钱,amount从SendEther合约的balance中来
function sendViaCall(address payable _to, uint amount) public {
// 调用了ReceiveEther的fallback函数
(bool sent, bytes memory data) = _to.call{value:amount}("");
require(sent, "Fail to send");
}

function sendViaFoo(address payable _to, uint amount) public {
ReceiveEther re = ReceiveEther(_to);
re.foo{value:amount}();
}
}

 回顾一下之前的NumberStorage.sol的代码,其功能为:线下募资,别人给他美金。然后开发者创建一种代币,叫xcl,并在constructor构造函数中将这些代币分发给投资者。下面我们要将线下募资环节转移到线上,即线上募资Eth以太币。

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
// SPDX-License-Identifier: NONE

pragma solidity 0.8.12;

contract NumberStorage{

string public name = "xcl";
string public symbol = "$";
uint public decimal = 4;
mapping(address=>uint) public balanceOf;

// 合约部署者初始有多少代币
constructor(uint x){
balanceOf[msg.sender] = x;
}

event TransferEvent(uint oldv, uint newv);

modifier notTooLarge(uint _x){
require(_x < 10000, "too large");
_;
}

function transfer(address to, uint amount) public {
address from = msg.sender;
if(balanceOf[from] < amount){
revert("not enough!");
}
balanceOf[to] += amount;
balanceOf[from] -= amount;
}

// 线上募资以太币eth
// 给我多少eth,我发给他多少xcl代币
function mint() public payable {
balanceOf[msg.sender] = balanceOf[msg.sender] + msg.value;
}

// 查看当前合约中有多少eth,而不是xcl代币
function getBalance() public view returns (uint) {
return address(this).balance;
}

// 合约部署者(募资人)将eth取走,即合约向外部钱包(募资人的钱包)转账
// 但是任何人都可以提钱,这个函数很有问题,缺少访问控制
function withdraw() public {
uint bal = address(this).balance;
msg.sender.call{value:bal}("");
}
}

 如何解决上述问题(任何人都可以提钱),即在上述代码中加入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// SPDX-License-Identifier: NONE

pragma solidity 0.8.12;

contract NumberStorage{
address private owner;
modifier isOwner() {
require(msg.sender == owner, "Caller is not owner");
_;
}
constructor() {
owner = msg.sender;
}
...
function withdraw() public isOwner {
uint bal = address(this).balance;
msg.sender.call{value:bal}("");
}
}

知识点补充

Solidity合约举例网址:solidity-by-example.org

数组:

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
pragma solidity ^0.8.17;

contract Array {
// 不定长数组
uint[] public arr;
uint[] public arr2 = [1, 2, 3];
// 定长数组
uint[10] public myFixedSizeArr;

function get(uint i) public view returns (uint) {
return arr[i];
}

function getArr() public view returns (uint[] memory) {
return arr;
}

// 不定长数组(动态数组)可以使用push与pop来增加数组元素,定长数组不可以
function push(uint i) public {
arr.push(i);
}
function pop() public {
arr.pop();
}
function getLength() public view returns (uint) {
return arr.length;
}
function remove(uint index) public {
delete arr[index];
}

function examples() external {
// 创建一个不定长数组,其中有元素5
uint[] memory a = new uint[](5);
}
}

结构体struct:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract Todos {
struct Todo {
string text;
bool completed;
}

// Todo结构体数组
Todo[] public todos;

function create(string calldata _text) public {
// todos结构体数组添加新的Todo元素的第1种方式
todos.push(Todo(_text, false));

// todos结构体数组添加新的Todo元素的第2种方式
todos.push(Todo({text: _text, completed: false}));

// 初始化Todo元素,并添加到todos数组中
Todo memory todo;
todo.text = _text;
todos.push(todo);
}

// 取todos数组中的一个Todo元素
function get(uint _index) public view returns (string memory text, bool completed) {
Todo storage todo = todos[_index];
return (todo.text, todo.completed);
}

// 更新todos数组中其中一个Todo元素的text
function updateText(uint _index, string calldata _text) public {
Todo storage todo = todos[_index];
todo.text = _text;
}

// 更新todos数组中其中一个Todo元素的completed
function toggleCompleted(uint _index) public {
Todo storage todo = todos[_index];
todo.completed = !todo.completed;
}
}

注:solidity中的storage和memory是两种不同的数据存储位置,不加说明的话,默认放到storage中。它们类似于计算机的硬盘和内存。storage是合约的永久性数据存储位置,它会在函数调用之间保持不变。memory是合约的临时性数据存储位置,它只在函数调用期间有效,函数调用结束后就会被删除。memory中的数据可以通过指针或引用来访问和修改。calldata是外部函数的动态参数存储位置,它类似于memory,但是只能读取不能修改。

常量:

1
2
3
4
5
6
7
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract Constants {
address public constant MY_ADDRESS = 0x777788889999AaAAbBbbCcccddDdeeeEfFFfCcCc;
uint public constant MY_UINT = 123;
}

Immutable(不可变性):

 表示只能改变一次。immutable修饰的变量可以和常量、字面量(就直接是数字、字符)或其他immutable变量进行比较,但是不能和普通的状态变量或内存变量进行比较。

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract Immutable {
address public immutable MY_ADDRESS;
uint public immutable MY_UINT;

constructor(uint _myUint) {
MY_ADDRESS = msg.sender;
MY_UINT = _myUint;
}
}

Gas:

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract Gas {
uint public i = 0;
// 运行这个函数,直到gas被消耗光
function forever() public {
while (true) {
i += 1;
}
}
}

Inheritance(继承性):

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

/* 继承图
A
/ \
B C
/ \ /
F D,E
*/

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

// 继承关键字为is,相当于java的extends,C++的“:”
contract B is A {
// 重写 A.foo()
function foo() public pure virtual override returns (string memory) {
return "B";
}
}

contract C is A {
// 重写 A.foo()
function foo() public pure virtual override returns (string memory) {
return "C";
}
}

contract D is B, C {
// D.foo() 返回 "C"
// 因为C是最右边(`contract D is B,C`决定了C在右边)的父合同,且有函数foo()
function foo() public pure override(B, C) returns (string memory) {
return super.foo();
}
}

// 继承必须从 "最基本(根)"到 "最派生" 排序,`contract F is A, B`一交换,就错了
contract F is A, B {
function foo() public pure override(A, B) returns (string memory) {
return super.foo();
}
}

注:多重继承指的是一个派生类可以有两个或多个基类。这意味着一个子类可以拥有多个父类,并且继承它们所有的成员变量和成员函数。然而,多重继承容易让代码逻辑复杂、思路混乱,一直备受争议。

除了动态调用(call)与静态调用(导入代码调用)之外,还用过哪些不被推荐的转账方式呢?

1
2
3
4
/* 将钱转出的一方要调用的函数 */
address.transfer // 类似于address.call
address.send
receive // 类似于fallback, constructor

 其中address.transferaddress.send与call转账方式相比,在gas_limit上有区别,address.transferaddress.send不能指定gas_limit(只能是2300个,为了防止重入攻击),而call方式可以指定gas_limit的大小(默认的是剩下多少给多少)。如果发送方中定义了address.transfer(或者address.send),那么合约的接收方也一定要定义receive函数。

补充:什么是重入攻击(Re-Entrancy)?看下面的样例合约:

1
2
3
4
5
6
7
8
9
10
pragma solidity ^0.4.23;

contract babybank {
mapping(address => uint) public balance;
address owner;

function withdraw(uint amount) public{
msg.sender.call.value(amount*100000000000000)();
}
}

 如果msg.sender本身就是一个合约的话,call调用在转账的时候会调用该合约的fallback函数。这时候如果构造一个恶意的合约,在它的fallback函数里面再次调用一次样例合约中的withdraw函数,这样就相当于调用了2次withdraw,经过了2次balance[msg.sender] -= amount;。当然,在其fallback函数里,可以继续调用该withdraw函数,无限循环,也可以在某种情形下(比如账户中的奖励点数已经足够多了)中止调用。
补充:什么是DeFi?

 DeFi( 去中心化金融:Decentralized Finance)指的是建立在区块链技术上的金融应用系统,其设计是开放和无许可的。Solidity是一种编程语言,用于在Ethereum和其他支持它的区块链平台上编写智能合约。它被用来在这些平台上构建DeFi应用程序,最著名的就是Uniswap。

Delegatecall

call是什么?call是调用其他合约函数的一种方式,且使用call可以进行转账。

在合约不可变的情况下进行合约的升级,要用到delegatecall。

成员变量与状态变量在合约存储空间中的分布

值变量(也就是uint,bool等定长的变量),他们所占的内存空间大小是固定的。值变量的存储顺序是按照定义的顺序来存储的,uint是32字节,byte1占用1个字节。如果一个数据类型没有占满一个存储槽(32字节),那么如果又来了一个byte1,可以放到这个槽里,那他俩就一个槽,如果放不下,那么就放到下一个槽里,但也有例外,例如,struct与array总是新开辟一个存储槽(32字节),且它们总是占用整数个存储槽,这些存储槽不与其他变量共享。

动态类型变量(例如Mapping,uint[])的存放,例如顺序定义了uint s1; mapping m; uint s2,其中mapping m前后是两个uintmapping占用的大小总是32字节(一个存储槽),其相当于一个指针,指向远处的一段内存。

 其计算公式为index=keccak256(keccak256(k),p),keccak256为哈希函数,k为mapping的某个键,p为mapping所在的插槽的下标,index为那段内存的位置。这个意思是mapping中键为k对应的值,存储在index指向的内存中。

delegatecall介绍

  原来的call调用图:

image-20230308145337761

 现在的delegatecall调用图:

image-20230308145359555

  之前b1与b2只能访问contractB中的变量,现在将b1与b2拉到contractA中,那么b1与b2相当于contractA的函数,所以能访问contractA中的变量,所见到的msg也不是message2了,而是message1'(不是message1)。即delegatecall是将另一个合约中的函数拿来当作自己合约的内部函数来用。message1message1'msg.sendermsg.value相同,而msg.datamsg.gaslimit可以不同。

  那么,如果contractB与contractA中都有叫x的变量,而b1函数中进行了对变量x的操作,那么当调用delegatecall后,b1被放到了contractA里,其操作的变量x也是contractA中的x。(这要求contractB与contractA有可兼容的存储布局)

  有如下代码:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

contract B {
uint public num;
address public sender;
uint public value;
bytes public cdata;

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

// A调用了B中的函数
contract A {
// A的存储布局与B一模一样
uint public num;
address public sender;
uint public value;
bytes public cdata;

function setVars(address _contract, uint _num) public payable {
(bool suc, bytes memory data) = _contract.delegatecall(
abi.encodeWithSignature("setVars(uint256)", _num)
);
}
}
// 最终,contractB不会有任何改变,而contractA中的变量发生了改变。

Delegatecall与call的级联调用结论:

image-20230406141940186

函数调用机制总结

  由于mapping是这样存储的:index=keccak256(keccak256(k),p),因此solidity中的mapping无法遍历(当然自己可以把key存起来就好) ,其中p是mapping在存储槽中的位置,k是键,存储槽p中不存储任何关于mapping的数据。而动态数组不一样,动态数组的存储槽p中放的是此数组的长度。举例说明一下遍历方法:

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

// SPDX-License-Identifier: NONE

pragma solidity 0.8.12;

contract NumberStorage{
string public name = "xcl";
string public symbol = "$";
uint public decimal = 4;

// 我们要遍历balanceOf这个Mapping
mapping(address=>uint) public balanceOf; // 定义余额
address[] holders;

// 添加与删除用户
function add(address holder) internal {
holders.push(holder);
}
function remove(address holder) internal {
uint index = 0;
for(uint i = 0; i < holders.length; i++){
if(holder == holders[i]){
index = i;
}
}
address l = holders[holders.length];
holders[index] = l;
holders.pop();
}


function mint() public payable {
balanceOf[msg.sender] = balanceOf[msg.sender] + msg.value;
add(msg.sender);
}

// 进行分红,就要遍历Holders
function divident() public {
for(uint i = 0; i < holders.length; i++){
uint bal = balanceOf[holders[i]];
...
}
}

}

 补充: 当使用calldata来调用合约的函数时(用call函数),不管有没有改变合约的状态,即使是读,也会产生一个交易,广播到区块链网络。(猜测应该是abi.encodeWithSignature的问题)因此,如果是view(读操作)或者pure(不是读也不是写)函数,那么我们最好不要使用call函数来调用,而是使用interface重载的方式来调用。

变量存储位置

 数组,字符串,结构体,Mapping(Mapping只在storage中,不在memory中)

 相同存储位置的变量赋值没有拷贝,只是引用,但是不同存储位置的变量赋值意味着拷贝(相应的消耗gas,执行的EVM机器码多了)。

 calldata(msg.data)是Immutable的,一旦赋值,不可更改。

 下面给出一个示例,讲一下calldata、memory与storage:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

contract DataLocations {
// arr, map, myStructs[1]一开始存在storage中
uint[] public arr;
mapping(uint=>address) map;
struct MyStruct{
uint foo;
}
mapping(uint=>MyStruct) myStructs;

function f() public {
_f(arr, map, myStructs[1]);

// myStructs是Mapping,存储在storage里,相同存储位置之间的赋值不消耗Gas,只是引用。
MyStruct storage myStruct = myStructs[1];

// 在memory中创建一个结构体myMemStruct,foo=0
MyStruct memory myMemStruct = MyStruct(0);
}

function _f(
// 这三个变量原来就在storage中,所以只是会产生引用,不会拷贝(不消耗gas),但是_f对三个变量的修改直接能返回(合约中能看到),即相当于传了指针
uint[] storage _arr,
mapping(uint=>address) storage _map,
MyStruct storage _myStruct
) internal {
...
}


function _f(
// _arr与_myStruct进行了拷贝(消耗gas),相当于传了值
uint[] memory _arr,
mapping(uint=>address) storage _map,
MyStruct memory _myStruct
) internal {
...
}

// 外部合约能调用g函数,这样的话_arr就是calldata类型的,这里转成了memory类型的,发生了拷贝
function g(uint[] memory _arr) public returns(uint[] memory) {
...
}
}

Time:2023-03-08


 关于转账的补充:

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
pragma solidity 0.8.13;

contract MyErc20Token{
string public name = "XCL";
string public symbol = "$";
uint public decimals = 4;
address public owner;
mapping(address=>uint) public balanceOf;

constructor(){
owner = msg.sender;
}
function mint() public payable {
balanceOf[msg.sender] += msg.value;
}

event TransferEvent(uint oldv, uint newv);

modifier isOwner(){
require(msg.sender == owner, "not owner!");
_;
}

function transfer(address to, uint amount) public {
address from = msg.sender;
uint current = balanceOf[from];
if(current <= amount)
revert("not enough balance");
uint toc = balanceOf[to];

current -= amount;
toc += amount;
balanceOf[from] -= amount;
balanceOf[to] = toc;
}
function withdraw() external isOwner(){
// call函数的执行逻辑,如果owner有receive,那么先使用receive,否则就使用fallback
(bool suc, bytes memory data) = owner.call{value:address(this).balance}("");
require(suc, "failed");
}

// 当某个人直接使用transfer转账给此合约以太币时,而没有使用mint,此时这个人就是吃亏的,因为这个人账户上的钱并没有变多,反而以太币的账户钱变多了。
// fallback用于使用call直接转账的情况
fallback() external payable{
mint();
}
// 只有实现了receive函数,send与transfer函数才能被调用,重点!!
receive() external payable{
// 其gas有限制:2300,仅够做一个日志emit,mint函数调用不了
// 所以就不写receive函数了,别人无法用send与transfer函数(调用的话会失败),只能用call函数
// 如果写了receive函数,那么别人转账之后,mint实现不了,别人账户上的钱也不会增加
// 所以宁可不写receive,让调用失败
mint();
}

}

代理模式与合约升级

 为什么要升级?(1)合约要稳定,技术上区块链的imutability不可变性,但是合约可能修订,由于漏洞,被攻击时需要紧急处理。(2)由于合约技术上的不可变性,升级需要特殊技术支持。(3)升级应不伤害去中心化与民主,即升级是由个人决定的,还是所有的人共同决定的。

 代理模式是用来支持智能合约升级的。如下图所示:

image-20230406122633754

 Proxy是一个合约,但是只存放了合约的数据(例如ERC20中的Balance、Name等),其相关的处理逻辑(函数)存放在不同的implementation中。这可以proxy使用delegatecall调用implemention中的函数完成。

 代理模式需要注意的点:(1)proxy和implementation内存布局要兼容;(2)proxy有成员变量指implementation,并且有函数可以切换implementation;(3)proxy实现fallback函数,不实现功能函数,fallback调用implementation.call并传递msg.data;(4)implementation实现功能函数并被proxy转发的calldata解析调用;(5)proxy的调用者用自己定义的功能接口调用proxy。

 上述看着有点晦涩难懂,直接上代码:

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
pragma solidity ^0.8.13;

// 目的是产生一个abi(应用二进制接口)
// 把proxy与v1联系起来后,把proxy重载成ProxyInterface的接口
interface ProxyInterface {
function inc() external;
}

contract Proxy {
address public implementation;
uint public x;

function setImplementation(address _imp) external {
implementation = _imp;
}

// fallback调用
function _delegate(address _imp) internal virtual {
(bool suc, bytes memory data) = _imp.delegatecall(msg.data);
if(!suc)
revert("failed");
}
// 当客户端把proxy当作有某种丰富功能的合约时,proxy都会调用fallback函数,proxy把消息传给implementation合约
fallback() external payable {
_delegate(implementation);
}
}

contract V1 {
// 内存布局与Proxy兼容
address public implementation;
uint public x;

function inc() external {
x += 1;
}
}

contract V2 {
address public implementation;
uint public x;

function inc() external {
x += 1;
}

function dec() external {
x -= 1;
}
}

 代码的功能是:将Proxy重载为ProxyInterface的接口,并调用Inc函数。重载流程如下:

image-20230406125631894

 升级流程:(1)重新写ProxyInterface接口(也可以不更新);(2)写V2版本的处理逻辑;(3)调用Proxy的setImplementation以部署新的逻辑。

知识补充:

 由于Proxy与V1的内存布局相同,在翻译成机器码后,机器码中是没有x变量名的,它只认是什么类型的第几个slot。因此,即使Proxy中不声明x,程序也能运行。因为Proxy中默认有这个slot的位置,所以将proxy中的变量x改为y,V1中的变量还是叫x,这样inc之后proxy中的y就会加1。

 Proxy的数据定义可以完全是白纸,但是最后改变的还是白纸(Proxy)中的slot。

 Remix中的At Address是将一个合约重载成一个接口,前提是给定相应的合约地址。可以改成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pragma solidity ^0.8.13;

interface ProxyInterface {
function inc() external;
}

contract Client {
address proxy;
constructor(address _proxy) public {
proxy = _proxy;
}
function inc_client() public {
ProxyInterface pi = ProxyInterface(proxy);
pi.inc();
}
}

用汇编绕过fallback不能有返回值的限制

 上上个代码中,有一些问题,具体在:

1
2
3
4
5
6
7
8
9
10
...
function _delegate(address _imp) internal virtual {
(bool suc, bytes memory data) = _imp.delegatecall(msg.data);
if(!suc)
revert("failed");
}
fallback() external payable {
_delegate(implementation);
}
...

 存在的问题是,_imp.delegatecall(msg.data)的返回值为data,而程序逻辑并没有对data进行处理,而fallback函数又不能定义返回值。如何解决呢?用汇编来写,来绕过fallback不能有返回值的限制。如下所示:(代码没看懂,老师也没讲

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
function _delegate(address _imp) internal virtual {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), _imp, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())

switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
fallback() external payable {
_delegate(implementation());
}
...

非结构化存储

 上上上上代码中,Proxy只用到了implementation,没用x,而V1只用到了x,没用implementation。这样做是为了保证Proxy与implementation的存储布局相同。下面我们使用非结构化存储,对其进行升级。

 非结构化存储思想:proxy低位存储中留下一张白纸,由V1操作的业务数据(这里指变量x)一律不作定义,留给V1管理,读和写都由V1来进行。proxy自己需要的控制性变量(例如implementation)通过指定slot,从而避开低位slot。这也需要汇编来完成,proxy使用sload与sstore,将控制性变量放在某个指定的位置。

支持返回值与非结构化存储的总体代码(非常值得好好看)

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
64
65
66
67
68
69
pragma solidity ^0.8.13;

interface ProxyInterface {
function inc() external;
// 看view时候需要返回值
function x() external view returns(uint);
}

contract Proxy {
// 非结构化存储,把implementation放到了某个位置
bytes32 private constant implementationPosition = keccak256("org.zeppelinos.proxy.implementation");
function upgradeTo(address newImplementation) public {
address currentImplementation = implementation();
setImplementation(newImplementation);
}
function implementation() public view returns(address impl) {
bytes32 position = implementationPosition;
assembly {
impl := sload(position)
}
}
function setImplementation(address newImplementation) internal {
bytes32 position = implementationPosition;
assembly {
sstore(position, newImplementation)
}
}

function _delegate(address _imp) internal virtual {
// 用汇编处理fallback没有返回值的问题,用来返回x的值,以后可以直接将Proxy重载成ProxyInterface,从而能看到x
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), _imp, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())

switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}

fallback() external payable {
_delegate(implementation());
}
}

contract V1 {
uint public x;

function inc() external {
x += 1;
}
}

contract V2 {
uint public x;

function inc() external {
x += 1;
}

function dec() external {
x -= 1;
}
}

openzepellin:智能合约的基础库,包含合约升级

库(library)

 库:代码复用,solidity的库也是合约。

 solidity的库的特征:(1)只有逻辑,没有数据(没有成员变量)。(2)不能payable。(3)不能继承,简化代码。(4)modifier对外不可见,外部合约不能调用modifier。

 代码结构如下:

1
2
3
4
pragma solidity ^0.8.13;
library libraryName {
...
}

 调用方式如下:

1
import LibraryName from "./library-file.sol"

Using关键字:语法糖

 相关示例如下:

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
pragma solidity ^0.8.13;

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

library Math {
function sqrt(uint y) internal pure returns(uint z) {
if(y > 3){
z = y;
uint x = y / 2 + 1;
while(x < z) {
z = x;
x = (y / x + x) / 2;
}
} else if (y != 0) {
z = 1;
}
}
}

contract TestSafeMath {
// 把uint类型的变量都加上Math库中所定义的函数,后面就可以直接用x.sqrt而不是Math.sqrt(x)
using Math for uint;
uint public MAX_UINT = 2**256 - 1;

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

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

 下面要解释上述代码的一些问题。

  • Library中的函数为什么定义成Internal?

 定义成Internal之后,在TestSafeMath编译时,SafeMath.add就会自动插入到TestSafeMath编译后的代码中(Inline),此时库并未被单独部署。但是,如果库中有public的函数(只要有一个public),库就会被单独部署为合约,假如调用者调用了这个Public函数,那么将会是delegatecall类型的调用。

 如果库中的函数被多次调用,那么用Internal修饰时,就会拷贝多份。如果使用Public修饰,那么就只有一份,也就是被部署过的库。

  • Library与代理模式的区别?

 代理模式(V1)中有成员变量,其成员变量内存布局要与调用者(Proxy)相同,而Library中不允许有成员变量。那怎么办呢?一般来说,函数的参数并不是storage的,但是Library允许函数参数是storage的,这样就能改变调用者的一些值。

  • 代理模式能进行合约升级,为什么不能用Library来进行合约升级呢?

 调用者对单独部署的library的引用是在编译时完成,不是运行时,无法实现动态升级。而代理模式则可以在运行时升级。

示例

1
在群中,还未加群,等到加上群了再看。

留言

2023-02-09

© 2024 wd-z711

⬆︎TOP