solidity基础003
关键词:ABI delegatecall create/create2 selector try catch
ABI编码解码
ABI (Application Binary Interface,应用二进制接口)是与以太坊智能合约交互的标准。数据基于他们的类型编码;并且由于编码后不包含类型信息,解码时需要注明它们的类型。
ABI编码:
1. abi.encode
用于和合约交互,并将每个参数填充为32字节的数据,并拼接在一起
1 | function encode() public view returns(bytes memory result) { |
编码的结果为
0x000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000007a58c0be72be218b41c608b7fe7c5bb630736c7100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000043078414100000000000000000000000000000000000000000000000000000000
由于abi.encode将每个数据都填充为32字节,中间会有很多0。
2. abi.encodePacked
将给定参数根据其所需最低空间编码。它类似 abi.encode,但是会把其中填充的很多0省略。比如,只用1字节来编码uint8类型。当你想省空间,并且不与合约交互的时候,可以使用abi.encodePacked,例如算一些数据的hash时
1 | function encodePacked() public view returns(bytes memory result) { |
编码的结果为
0x000000000000000000000000000000000000000000000000000000000000000a7a58c0be72be218b41c608b7fe7c5bb630736c713078414100000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000006
,由于abi.encodePacked对编码进行了压缩,长度比abi.encode短很多。
3. abi.encodeWithSignature
与abi.encode功能类似,只不过第一个参数为函数签名,比如”foo(uint256,address,string,uint256[2])”。当调用其他合约的时候可以使用。等同于在abi.encode编码结果前加上了4字节的函数选择器。 函数选择器就是通过函数名和参数进行签名处理(Keccak–Sha3)来标识函数,可以用于不同合约之间的函数调用
4. abi.encodeWithSelector
与abi.encodeWithSignature功能类似,只不过第一个参数为函数选择器,为函数签名Keccak哈希的前4个字节
ABI解码:
abi.decode
abi.decode用于解码abi.encode生成的二进制编码,将它还原成原本的参数。
1 | function decode(bytes memory data) public pure returns(uint dx, address daddr, string memory dname, uint[2] memory darray) { |
ABI的使用场景
1. 在合约开发中,ABI常配合call来实现对合约的底层调用。
1 | bytes4 selector = contract.getValue.selector; |
2. ethers.js中常用ABI实现合约的导入和函数调用。
1 | const wavePortalContract = new ethers.Contract(contractAddress, contractABI, signer); |
3. 对不开源合约进行反编译后,某些函数无法查到函数签名,可通过ABI进行调用。
Hash在solidity的应用
一个好的哈希函数应该具有以下几个特性:
单向性:从输入的消息到它的哈希的正向运算简单且唯一确定,而反过来非常难,只能靠暴力枚举。
灵敏性:输入的消息改变一点对它的哈希改变很大。
高效性:从输入的消息到哈希的运算高效。
均一性:每个哈希值被取到的概率应该基本相等。
抗碰撞性:
- 弱抗碰撞性:给定一个消息x,找到另一个消息x’,使得hash(x) = hash(x’)是困难的。
- 强抗碰撞性:找到任意x和x’,使得hash(x) = hash(x’)是困难的。
生成数据唯一标识
加密签名
安全加密
Solidity中常用的哈希函数:
- Keccak256
用法:哈希 = keccak256(数据);
(Sha3和Keccak256不是同一物:Ethereum和Solidity智能合约代码中的SHA3是指Keccak256,而不是标准的NIST-SHA3,为了避免混淆,直接在合约代码中写成Keccak256是最清晰的)
keccak256来生成一些数据的唯一标识
弱抗碰撞性(即给定一个消息x,找到另一个消息x’,使得hash(x) = hash(x’)是困难的)
1 | // 弱抗碰撞性 |
- 强抗碰撞性(到任意不同的x和x’,使得hash(x) = hash(x’)是困难的。)
1 | // 强抗碰撞性 |
Delegatecall
定义
是Solidity中地址类型的低级成员函数
和call不一样,delegatecall在调用合约时可以指定交易发送的gas,但不能指定发送的ETH数额
注意:delegatecall有安全隐患,使用时要保证当前合约和目标合约的状态变量存储结构相同,并且目标合约安全,不然会造成资产损失。
语法
1 | 目标合约地址.delegatecall(二进制编码) |
ps: 二进制编码用结构化编码函数abi.encodeWithSignature
获得
1 | abi.encodeWithSignature("函数签名",逗号分隔的具体参数) |
应用场景
代理合约(Proxy Contract):将智能合约的存储合约和逻辑合约分开:代理合约(Proxy Contract)存储所有相关的变量,并且保存逻辑合约的地址;所有函数存在逻辑合约(Logic Contract)里,通过delegatecall执行。当升级时,只需要将代理合约指向新的逻辑合约即可。
EIP-2535 Diamonds(钻石):钻石是一个支持构建可在生产中扩展的模块化智能合约系统的标准。钻石是具有多个实施合约的代理合约。
对比
call调用&dalecatecall调用
ps:
函数签名为”函数名(逗号分隔的参数类型)”。例如
1 | abi.encodeWithSignature("f(uint256,address)", _x, _addr) |
首先:写一个被调用的合约C
1 | contract C { |
再写:发起调用的合约B
1 | //合约B必须和目标合约C的变量存储布局必须相同 |
接下来,分别用call
和delegatecall
来调用合约C
的setVars
函数,更好的理解它们的区别
用call调用:
1 | // 通过call来调用C的setVars()函数,将改变合约C里的状态变量 |
运行后,合约C中的状态变量将被修改:num被改为10,sender变为合约B的地址
用dalegatecall调用
1 | // 通过delegatecall来调用C的setVars()函数,将改变合约B里的状态变量 |
由于是delegatecall,上下文为合约B。在运行后,合约B中的状态变量将被修改:num被改为100,sender变为你的钱包地址。合约C中的状态变量不会被修改。
总结
当用户A通过合约B来delegatecall合约C时,执行了( ) 的函数,语境是 ( ) ,msg.sender和msg.value来自( ) ,并且如果函数改变一些状态变量,产生的效果会作用于( ) 的变量上
所以答案为:C,B,A,B
当用户 A 通过合约 B 使用 delegatecall 调用合约 C 时,以下情况会发生:
- 执行了 C 的函数:
delegatecall 会调用目标合约(即合约 C)中的指定函数代码。
- 语境是 B:
delegatecall 会在调用者合约(即合约 B)的上下文中执行代码。这意味着合约 C 的代码会在合约 B 的存储和上下文中运行,就像这段代码属于 B 一样。
- msg.sender 和 msg.value 来自 A:
delegatecall 保留了原始调用者的信息。也就是说,msg.sender 和 msg.value 都来自于发起调用的用户 A。
- 状态变量的影响作用于 B 的变量上:
由于 delegatecall 在调用合约 B 的存储和上下文中执行,所以任何状态变量的修改都只会影响合约 B 中的变量,不会影响合约 C。
在合约中创建新合约
智能合约同样也可以创建新的智能合约
去中心化交易所uniswap就是利用工厂合约(PairFactory)创建了无数个币对合约(Pair)
关于工厂合约
Pair
合约很简单,包含3个状态变量:factory,token0和token1。
构造函数constructor在部署时将factory赋值为工厂合约地址。initialize函数会由工厂合约在部署完成后手动调用以初始化代币地址,将token0和token1更新为币对中两种代币的地址。
工厂合约(PairFactory)有两个状态变量getPair是两个代币地址到币对地址的map,方便根据代币找到币对地址;allPairs是币对地址的数组,存储了所有代币地址。
PairFactory合约只有一个createPair函数,根据输入的两个代币地址tokenA和tokenB来创建新的Pair合约。其中
1 | Pair pair = new Pair(); |
就是创建合约的代码
当 PairFactory 合约调用 new Pair() 来创建一个新的 Pair 合约实例时,Pair 合约的 msg.sender 将是 PairFactory 合约本身
Create
用法
1 | //new一个合约,并传入新合约构造函数所需的参数 |
Create2
作用
让合约地址独立于未来的事件
不管未来区块链上发生了什么,你都可以把合约部署在事先计算好的地址上。用CREATE2创建的合约地址由4个部分决定:
· 0xFF:一个常数,避免和CREATE冲突
· CreatorAddress: 调用 CREATE2 的当前合约(创建合约)地址。
· salt(盐):一个创建者指定的bytes32类型的值,它的主要目的是用来影响新创建的合约的地址。
· initcode: 新合约的初始字节码(合约的Creation Code和构造函数的参数)。
1 | 新地址 = hash("0xFF",创建者地址, salt, initcode) |
如何使用
CREATE2的用法和之前讲的CREATE类似,同样是new一个合约,并传入新合约构造函数所需的参数,只不过要多传一个salt参数:
1 | Contract x = new Contract{salt: _salt, value: _value}(params) |
构建工厂合约2
1 | contract PairFactory2{ |
工厂合约(PairFactory2)有两个状态变量getPair是两个代币地址到币对地址的map,方便根据代币找到币对地址;allPairs是币对地址的数组,存储了所有币对地址。
PairFactory2合约只有一个createPair2函数,使用CREATE2根据输入的两个代币地址tokenA和tokenB来创建新的Pair合约。其中
1 | Pair pair = new Pair{salt: salt}(); |
就是利用CREATE2创建合约的代码,非常简单,而salt为token1和token2的hash:
1 | bytes32 salt = keccak256(abi.encodePacked(token0, token1)); |
实际应用场景
- 交易所为新用户预留创建钱包合约地址。
- 由 CREATE2 驱动的 factory 合约,在Uniswap V2中交易对的创建是在 Factory中调用CREATE2完成。这样做的好处是: 它可以得到一个确定的pair地址, 使得 Router中就可以通过 (tokenA, tokenB) 计算出pair地址, 不再需要执行一次 Factory.getPair(tokenA, tokenB) 的跨合约调用。
selfdestruct
selfdestruct命令可以用来删除智能合约,并将该合约剩余ETH转到指定地址。
不建议使用
目前来说:
- 已经部署的合约无法被SELFDESTRUCT了。
- 如果要使用原先的SELFDESTRUCT功能,必须在同一笔交易中创建并SELFDESTRUCT
如何使用
1 | selfdestruct(_addr); |
其中_addr是接收合约中剩余ETH的地址。_addr 地址不需要有receive()或fallback()也能接收ETH
注意事项
对外提供合约销毁接口时,最好设置为只有合约所有者可以调用,可以使用函数修饰符onlyOwner进行函数声明。
当合约中有selfdestruct功能时常常会带来安全问题和信任问题,合约中的selfdestruct功能会为攻击者打开攻击向量(例如使用selfdestruct向一个合约频繁转入token进行攻击,这将大大节省了GAS的费用,虽然很少人这么做),此外,此功能还会降低用户对合约的信心。
函数选择器Selector
msg.data
msg.data是Solidity中的一个全局变量,值为完整的calldata(调用函数时传入的数据
举例:
当参数为0x2c44b726ADF1963cA47Af88B284C06f30380fC78
时,输出的calldata
为
0x6a6278420000000000000000000000002c44b726adf1963ca47af88b284c06f30380fc78
这段很乱的字节码可以分成两部分:
前4个字节为函数选择器selector:
0x6a627842
后面32个字节为输入的参数:
0x0000000000000000000000002c44b726adf1963ca47af88b284c06f30380fc78
其实calldata就是告诉智能合约,我要调用哪个函数,以及参数是什么。
method id、selector和函数签名
method id定义为函数签名的Keccak哈希后的前4个字节,当selector与method id相匹配时,即表示调用该函数,那么函数签名是什么?**
简单介绍函数签名: 为**”函数名(逗号分隔的参数类型)”。举个例子,上面代码中mint的函数签名为“mint(address)”**。在同一个智能合约中,不同的函数有不同的函数签名,因此我们可以通过函数签名来确定要调用哪个函数。
注意,在函数签名中,uint和int要写为uint256和int256。
由于计算method id时,需要通过函数名和函数的参数类型来计算。
在Solidity中,函数的参数类型主要分为:基础类型参数,固定长度类型参数,可变长度类型参数和映射类型参数。
- 基础类型参数
solidity中,基础类型的参数有:uint256(uint8, … , uint256)、bool, address等。在计算method id时,只需要计算bytes4(keccak256("函数名(参数类型1,参数类型2,...)"))
- 固定长度类型参数
通常为固定长度的数组,例如:uint256[5]等因此,在计算该函数的method id时,只需要通过bytes4(keccak256("fixedSizeParamSelector(uint256[3])"))
即可。
- 可变长度类型参数
通常为可变长的数组,例如:address[]、uint8[]、string等,
因此在计算该函数的method id时,只需要通过
1 | bytes4(keccak256("nonFixedSizeParamSelector(uint256[],string)")) |
即可。
- 映射类型参数
映射类型参数通常有:contract、enum、struct等。在计算method id时,需要将该类型转化成为ABI类型。因此,计算该函数的method id的代码为
1 | bytes4(keccak256("mappingParamSelector(address,(uint256,bytes),uint256[],uint8)")) |
使用selector
我们可以利用selector来调用目标函数。例如我想调用elementaryParamSelector函数,我只需要利用abi.encodeWithSelector将elementaryParamSelector函数的method id作为selector和参数打包编码,传给call函数:
1 | //使用selector来调用函数 |
try Catch
在 Solidity 中,try-catch 可以用来捕获以下几种异常:
- **revert()**:手动触发的异常,通常用于返回自定义错误消息。
- **require()**:检查条件,如果条件不满足则触发异常,通常用于输入验证和状态检查。
- **assert()**:用于检查不变量(internal consistency),如果条件不满足会触发异常,并消耗所有剩余的 gas。这种异常通常表示程序中有严重错误。
因此,try-catch 可以捕获 以上所有异常,但前提是它们发生在外部调用时。例如,当调用另一个合约或使用低级调用时发生异常,try-catch 可以捕获这些错误。
try-catch只能被用于external函数或创建合约时constructor(被视为external函数)的调用。基本语法如下:
1 | try externalContract.f() { |
其中externalContract.f()是某个外部合约的函数调用,try模块在调用成功的情况下运行,而catch模块则在调用失败时运行。
同样可以使用this.f()来替代externalContract.f(),this.f()也被视作为外部调用,但不可在构造函数中使用,因为此时合约还未创建。
如果调用的函数有返回值,那么必须在try之后声明returns(returnType val),并且在try模块中可以使用返回的变量;如果是创建合约,那么返回值是新创建的合约变量。
1 | try externalContract.f() returns(returnType val){ |
另外,catch模块支持捕获特殊的异常原因:
1 | try externalContract.f() returns(returnType){ |
题:
- 在代理合约中,存储所有相关的变量的是(),存储所有函数的是(),同时()
选择一个答案
A. 代理合约; 逻辑合约; 代理合约delegatecall逻辑合约
B. 代理合约; 逻辑合约; 逻辑合约delegatecall代理合约
C. 逻辑合约; 代理合约; 代理合约delegatecall逻辑合约
D. 逻辑合约; 代理合约; 逻辑合约delegatecall代理合约
解析
在代理合约模式中,通常有两个主要组成部分:代理合约和逻辑合约。它们的职责分配如下:
- 代理合约 存储所有相关的变量:
代理合约负责持有合约的状态(即存储变量)。这意味着合约的状态信息保存在代理合约中。
- 逻辑合约 存储所有函数:
逻辑合约包含具体的业务逻辑和函数实现。它不直接持有状态,而是通过 delegatecall 被代理合约调用。
- 代理合约使用 delegatecall 调用逻辑合约:
代理合约使用 delegatecall 调用逻辑合约中的函数。在这种调用方式下,逻辑合约中的代码在代理合约的上下文中执行,这意味着逻辑合约可以通过代理合约的状态变量进行操作。
因此,选项 A 是正确的,因为它准确地描述了代理合约和逻辑合约之间的关系和它们各自的职责。
使用delegatecall对当前合约和目标合约的状态变量有什么要求?
选择一个答案
A. 变量名、变量类型、声明顺序都必须相同
B. 变量名可以不同,变量类型、声明顺序必须相同
C. 变量类型可以不同,变量名、声明顺序必须相同
D. 声明顺序可以不同,变量名、变量类型必须相同
解析
- 状态变量在 delegatecall 中的作用:
o 当使用 delegatecall 调用目标合约的函数时,该函数的执行是在调用合约的存储上下文中进行的。这意味着目标合约中使用的状态变量会直接影响调用合约中的状态变量。
- 变量名:
o 变量名可以不同,这是因为在调用时,delegatecall 是根据存储位置而不是变量名来访问状态变量。只要存储顺序和类型匹配,变量名的不同不会影响操作。
- 变量类型:
o 变量类型必须相同,因为 delegatecall 需要确保数据的正确解码和存储。若目标合约中使用的变量类型与调用合约的状态变量类型不一致,将导致数据解码错误,从而引发异常。
- 声明顺序:
声明顺序必须相同,这是因为 Solidity 编译器在生成合约存储布局时是基于变量声明的顺序来分配存储位置的。如果顺序不同,虽然变量名可以不同,但不同的顺序会导致访问错误的数据位置
综上:选B
- 1个工厂合约PairFactory创建Pair合约的最大数量一般由什么决定?
选择一个答案
A. 1个PairFactory只能创建1个pari合约
B. Pair合约逻辑
C. PairFactory合约逻辑
解析
在 Solidity 中,工厂合约(如 PairFactory)的作用通常是用于批量创建和管理其他合约实例(例如 Pair 合约)。工厂合约能够创建的合约数量主要取决于工厂合约自身的逻辑。也就是说,PairFactory 中的代码决定了它创建 Pair 合约的具体规则和限制,例如是否允许创建多个 Pair 实例,或对创建数量施加其他限制。
- A. 1个PairFactory只能创建1个pair合约:不正确。工厂合约一般可以创建多个合约实例,具体数量取决于其逻辑实现。
- B. Pair合约逻辑:不正确。Pair 合约的逻辑通常只影响其自身的行为和状态,而不是 PairFactory 合约创建 Pair 合约的数量。
- C. PairFactory合约逻辑:正确。工厂合约的逻辑直接决定了它可以创建多少个 Pair 合约实例。
因此,答案是 C。
- 删除合约时,可以将合约中剩余的ETH发送出去: 选择一个答案 A. 正确 B. 错误
解析
在 Solidity 中使用 selfdestruct 删除合约时,可以将合约中剩余的 ETH 发送到指定的地址。selfdestruct(address payable recipient) 会销毁合约并将其剩余余额发送给 recipient 地址。因此,删除合约时确实可以将合约中的剩余 ETH 发送出去,选A。
当我们调用智能合约时,传递给合约的数据的前若干个字节被称为“函数选择器 (Selector)”,它告诉合约我们想要调用哪个函数。假设我们想要调用的函数在智能合约中定义声明如下:
1
solidity Copy code function foo(uint256 n, address sender, string s) public view returns(bool b)
那么该函数对应的函数选择器为: 选择一个答案
A.
"foo(uint256,address,string)"
B.
"foo(uint256 n, address sender, string s)"
C.
keccak256("foo(uint256,address,string)")
D.
keccak256("foo(uint256 n, address sender, string s)")
E.
bytes4(keccak256("foo(uint256,address,string)"))
F.
bytes4(keccak256("foo(uint256 n, address sender, string s)"))
解析
在 Solidity 中,函数选择器是由函数签名(函数名称和参数类型)经过 Keccak-256 哈希运算后生成的前 4 个字节。具体生成步骤如下:
1. 将函数的签名(包括函数名称和参数类型,但不包含参数名称)传入 keccak256 进行哈希计算。
在这个例子中,函数签名为 "foo(uint256,address,string)"
2. 取 keccak256 哈希结果的前 4 个字节,形成 bytes4 类型的数据。
因此,正确答案是 bytes4(keccak256(“foo(uint256,address,string)”))。
已知函数foo在智能合约中定义声明如下:
1
solidity Copy code function foo(uint256 a) public view
而字符串
"foo(uint256)"
的keccak256哈希值为:0x2fbebd3821c4e005fbe0a9002cc1bd25dc266d788dba1dbcb39cc66a07e7b38b
那么,当我们希望调用函数foo()时,以下生成调用数据的写法中,正确且最节省gas的一项是: 选择一个答案A.
abi.encodeWithSignature("foo(uint256)", a)
B.abi.encodeWithSelector("foo(uint256)", a)
C.
abi.encodeWithSelector(bytes(keccak256("foo(uint256)")), a)
D.
abi.encodeWithSelector(bytes4(0x2fbebd38), a)
解析:
· 在 Solidity 中,调用函数时可以通过函数的选择器(selector)生成调用数据。
· 题目中已给出 “foo(uint256)” 的哈希值为 0x2fbebd3821c4e005fbe0a9002cc1bd25dc266d788dba1dbcb39cc66a07e7b38b,而选择器就是这个哈希值的前四个字节,即 0x2fbebd38。
· 选项 D 使用了 abi.encodeWithSelector(bytes4(0x2fbebd38), a),直接利用已知的选择器生成调用数据,这是最节省 gas 的写法,因为它避免了重复计算哈希值。
其他选项分析:
· A 和 B 会导致额外的 gas 开销,因为它们需要在运行时计算 “foo(uint256)” 的哈希值。
· C 中的 keccak256(“foo(uint256)”) 也会增加不必要的计算,因此会消耗更多的 gas。
正确答案选D
- 如果对于某个哈希函数,我们统计大量不同字符串对应的哈希值(二进制串),发现其前 n 位全部为 0 的频率恰好约为 1/2^n,则我们认为该哈希函数具有良好的:
选择一个答案
A. 单向性
B. 灵敏性
C. 高效性
D. 均一性
E. 抗碰撞性
解析:
· 均一性(Uniformity)指的是哈希函数生成的哈希值在输出空间中均匀分布。若哈希函数具有均一性,则任意特定模式(如前 n 位为 0)在随机情况下出现的概率为 12n\frac{1}{2^n}2n1。
· 题目中的现象描述了哈希值分布的均匀性,符合均一性的定义。
其他选项分析:
· 单向性 是指给定哈希值很难逆向推出原始输入。
· 灵敏性 是指输入的细微变化(例如一位改变)会显著改变输出(哈希值)。
· 高效性 指的是哈希函数计算的速度。
· 抗碰撞性 是指很难找到不同输入生成相同的哈希值。
因此,D. 均一性 是最符合题意的选项。
1 | function transfer(address recipient, uint amount) external override returns (bool) { |
transfer函数的函数签名是transfer(address uint256)
transfer函数的选择器为0xa9059cbb
解析
通过计算 keccak256(“transfer(address,uint256)”) 得到
计算代码:
1 | pragma solidity ^0.8.0; |
- try-catch捕获到异常后是否会使try-catch所在的方法调用失败?
选择一个答案 A. 会 B. 不会
解析
在 Solidity 中,try-catch 用于捕获外部合约调用或低级调用(如 .call)中可能发生的异常。当 try 块中的调用失败并触发异常时,程序流会进入 catch 块,而不会导致整个 try-catch 所在的函数失败。因此,只要 catch 块正确处理了异常,try-catch 所在的方法可以继续执行,不会因为捕获异常而失败。
- try代码块内的revert是否会被catch本身捕获?
A. 会 B. 不会
解析
因为Solidity 中,try-catch 结构用于捕获由外部调用(如其他合约的函数调用或低级调用)引发的异常。然而,try 代码块内部的 revert 并不会被同一 try-catch 结构的 catch 捕获。这是因为:
- try-catch 只捕获外部合约调用中的异常。如果 try 块内的代码直接调用 revert(),这将导致该函数的整个执行环境被终止,无法进入 catch 块。
- 换句话说,try-catch 结构设计的目的是为了捕获异常以便处理,而 revert() 直接触发的异常会使当前调用栈中的所有状态都回滚,而不会跳转到 catch 块。
- 以下异常返回值类型为bytes的是: 选择一个答案 A. revert() B. require() C. assert() D. 以上都是 D是错的
解析: 在 Solidity 中,revert() 可以返回一个 bytes 类型的错误信息,例如自定义的错误消息,因此 revert() 的返回值类型为 bytes。这是为了提供详细的错误描述。
- require() 和 assert() 通常不返回详细的错误数据,尤其是 assert(),它直接导致系统错误,并消耗所有剩余的 gas,不返回 bytes 类型的错误信息。
因此,只有 A. revert() 返回 bytes 类型的错误信息。