您正在查看: Ethereum 分类下的文章

Solidity ABI编码解码

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

Solidity中,ABI编码有4个函数:abi.encode, abi.encodePacked, abi.encodeWithSignature, abi.encodeWithSelector。而ABI解码有1个函数:abi.decode,用于解码abi.encode的数据。这一讲,我们将学习如何使用这些函数。

ABI编码

我们将用编码4个变量,他们的类型分别是uint256, address, string, uint256[2]:

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

abi.encode

将给定参数利用ABI规则编码。ABI被设计出来跟智能合约交互,他将每个参数转填充为32字节的数据,并拼接在一起。如果你要和合约交互,你要用的就是abi.encode。

 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省略。比如,只用1字节来编码uint类型。当你想省空间,并且不与合约交互的时候,可以使用abi.encodePacked,例如算一些数据的hash时。

 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字节的函数选择器。

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.encode的二进制编码输入给decode,将解码出原来的参数:

总结

在以太坊中,数据必须编码成字节码才能和智能合约交互。这一讲,我们介绍了4种abi编码方法和1种abi解码方法。

转载自:https://mirror.xyz/wtfacademy.eth/jXJnvwkoQzvJaqVIxagxneSZim6Qxm-StuNNxLuKuw8

实例验证 - call delegatecall

实例验证下call,delegatecall,两种方式下msg.sender和数据storage存储位置的区别

合约A

pragma solidity ^0.8.19;

contract A {
    address public temp1;
    uint256 public temp2;

    function three_call(address addr) public {
        (bool success, bytes memory result) = addr.call(
            abi.encodeWithSignature("test()")
        ); // 1
        require(success, "The call to B contract failed");
    }

    function three_delegatecall(address addr) public {
        (bool success, bytes memory result) = addr.delegatecall(
            abi.encodeWithSignature("test()")
        ); // 2
        require(success, "The delegatecall to B contract failed");
    }
}

合约B

pragma solidity ^0.8.19;

contract B {
    address public temp1;
    uint256 public temp2;

    function test() public  {
        temp1 = msg.sender;
        temp2 = 100;
    }
}

call 测试

B合约地址:0x086866663330344C7D1C51Bf19FF981AF3cB5782
A合约地址:0x05715D87C062B9685DD877d307b584bAbec964Ed
交易发起地址:0x6BC0E9C6a939f8f6d3413091738665aD1D7d2776

执行前数据

A B
temp1 0x0000000000000000000000000000000000000000 0x0000000000000000000000000000000000000000
temp2 0 0

A合约执行three_call,参数为B合约地址
执行后数据

A B
temp1 0x0000000000000000000000000000000000000000 0x05715D87C062B9685DD877d307b584bAbec964Ed
temp2 0 100

delegatecall 测试

B合约地址:0x27153EDA2E085534811b040f6062f6528D6B80a1
A合约地址:0xd3C23F354Ca2160E6dC168564AB8954146cF35C9
交易发起地址:0x6BC0E9C6a939f8f6d3413091738665aD1D7d2776

执行前数据

A B
temp1 0x0000000000000000000000000000000000000000 0x0000000000000000000000000000000000000000
temp2 0 0

A合约执行three_delegatecall,参数为B合约地址
执行后数据

A B
temp1 address: 0x6BC0E9C6a939f8f6d3413091738665aD1D7d2776 0x0000000000000000000000000000000000000000
temp2 100 0

总结

  • A发起地址->B合约call->C合约
    msg.sender为B合约地址,非原始A发起地址,数据保存在C合约中
  • A地址->B合约delegatecall->C合约
    msg.sender为A发起地址,数据保存在B合约中

注意

callcode 已经在solidity 0.5+废弃,被delegatecall取代,故不做分析测试

TypeError: "callcode" has been deprecated in favour of "delegatecall".

参考
https://hicoldcat.com/posts/web3/senior-track-5/

实例验证 - 访问私有数据

方案的具体讲解来自《智能合约安全审计入门篇 —— 访问私有数据》,本片文章将在QEasyWeb3测试链上进行数据验证

测试合约

contract Vault {
    uint256 public count = 123;
    address public owner = msg.sender;
    bool public isTrue = true;
    uint16 public u16 = 31;
    bytes32 private password;
    bytes32[3] public data;
    struct User {
        uint256 id;
        bytes32 password;
    }
    User[] private users;
    mapping(uint256 => User) private idToUser;

    constructor(bytes32 _password) {
        password = _password;
    }

    function addUser(bytes32 _password) public {
        User memory user = User({id: users.length, password: _password});
        users.push(user);
        idToUser[user.id] = user;
    }
}

部署合约

通过https://remix.ethereum.org/ 进行部署,具体操作不做详细讲解

网络配置

QEasyWeb3的网络配置,可以查看《QEasyChain 测试链信息》

类型 RPC
https https://qeasyweb3.com
http http://qeasyweb3.com

chain id: 9528

合约操作

合约部署时,构造函数参数bytes32 _password,需要传入初始化参数,用于写入测试状态变量password
例如:0x4141414242424343430000000000000000000000000000000000000000000001

然后通过addUser再添加两个测试数据

  • 0x4141414242424343430000000000000000000000000000000000000000000002
  • 0x4141414242424343430000000000000000000000000000000000000000000003

执行完成后,开始slot的读取测试。

读取私有slot数据

为了简化操作,我们直接使用python3 + web3py进行测试脚本的编写, 也可以选用其他web3库,大同小异
我们上面合约部署完的合约地址是0x4398BdBD9eF8bcACc2A41Abc671BF8f428BB4904

测试脚本

import time
from web3 import Web3

w3 = Web3(Web3.HTTPProvider('http://qeasyweb3.com'))

if __name__ == "__main__":
    for i in range(0,7):
        print("slot"+str(i)+" = " + w3.toHex(w3.eth.getStorageAt("0x4398BdBD9eF8bcACc2A41Abc671BF8f428BB4904",hex(i))))

执行后返回数据如下

slot0 = 0x000000000000000000000000000000000000000000000000000000000000007b
slot1 = 0x000000000000000000001f016bc0e9c6a939f8f6d3413091738665ad1d7d2776
slot2 = 0x4141414242424343430000000000000000000000000000000000000000000001
slot3 = 0x0000000000000000000000000000000000000000000000000000000000000000
slot4 = 0x0000000000000000000000000000000000000000000000000000000000000000
slot5 = 0x0000000000000000000000000000000000000000000000000000000000000000
slot6 = 0x0000000000000000000000000000000000000000000000000000000000000002
  • slot0
    存储的为状态变量count的值,等于123的十六进制
  • slot1
    由于存储紧凑原则,当前slot存储了3部署数据,从右往左依次是
    • 状态变量owner的值,等于用于部署合约的msg.sender=0x6BC0E9C6a939f8f6d3413091738665aD1D7d2776
    • 状态变量isTrue = 01 = true
    • 状态变量u16 = 1f = 31
  • slot2
    存储的为状态变量password
  • slot3
    存储的为状态变量data第1个存储值 = data[0]
  • slot4
    存储的为状态变量data第2个存储值 = data[1]
  • slot5
    存储的为状态变量data第3个存储值 = data[2]
  • slot6
    存储的为变长数组状态变量users的长度,当前已执行两次addUser

查看私有变长数组数据

上面我们从slot6中查询到了变长数组users的长度,下面我们继续查看users中存储的数据。

我们先再次回顾下变长数组的存储原则
对于变长数组,会先启用一个新的插槽 slotA 用来存储数组的长度,其数据存储在另外的编号为 slotV 的插槽中。slotA 表示变长数组声明的位置,用 length 表示变长数组的长度,用 slotV 表示变长数组数据存储的位置,用 value 表示变长数组某个数据的值

对应的代码逻辑

length = sload(slotA)
slotV = keccak256(slotA) + index// 索引下标
value = sload(slotV)

依据变长数组的存储以及紧凑打包的原则,所以对于前面两次addUser的数据存储槽位置为

  • user1.id == keccak256(slotA) + 0
  • user1.password == keccak256(slotA) + 1
  • user2.id == keccak256(slotA) + 2
  • user2.password == keccak256(slotA) + 3

我们继续使用python3 + web3py进行验证
先计算keccak256(slotA)

print("slot6-keccak = " +w3.toHex(Web3.keccak(hexstr="0x0000000000000000000000000000000000000000000000000000000000000006")))

得到

0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d3f

所以user1和user2的存储槽位置为

  • user1.id == 0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d3f
  • user1.password == 0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d40
  • user2.id == 0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d41
  • user2.password == 0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d42

通过测试脚本,获取上面存储槽位置的值

print("user1.id = " + w3.toHex(w3.eth.getStorageAt("0x4398BdBD9eF8bcACc2A41Abc671BF8f428BB4904", "0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d3f")))
print("user1.password = " + w3.toHex(w3.eth.getStorageAt("0x4398BdBD9eF8bcACc2A41Abc671BF8f428BB4904", "0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d40")))
print("user2.id = " + w3.toHex(w3.eth.getStorageAt("0x4398BdBD9eF8bcACc2A41Abc671BF8f428BB4904", "0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d41")))
print("user2.password = " + w3.toHex(w3.eth.getStorageAt("0x4398BdBD9eF8bcACc2A41Abc671BF8f428BB4904", "0xf652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f377c0d42")))

得到数据如下

user1.id = 0x0000000000000000000000000000000000000000000000000000000000000000
user1.password = 0x4141414242424343430000000000000000000000000000000000000000000002
user2.id = 0x0000000000000000000000000000000000000000000000000000000000000001
user2.password = 0x4141414242424343430000000000000000000000000000000000000000000003

经测试与前面两次addUser的数据一致

总结

对于定长数组data,每个元素单独一个存储槽,所以data状态变量依次为slot3-5
对于变长数组users,会先启用一个新的插槽 slotA 用来存储数组的长度,其数据存储在另外的编号为 slotV 的插槽中。slotA 表示变长数组声明的位置,用 length 表示变长数组的长度,用 slotV 表示变长数组数据存储的位置,用 value 表示变长数组某个数据的值

智能合约安全审计入门篇 —— 访问私有数据

了解如何访问合约中的私有数据(private 数据)

前置知识

我们先来了解一下 solidity 中的三种数据存储方式:

1. storage(存储)

  • storage 中的数据被永久存储。其以键值对的形式存储在 slot 插槽中。

  • storage 中的数据会被写在区块链中(因此它们会更改状态),这就是为什么使用存储非常昂贵的原因。

  • 占用 256 位插槽的 gas 成本为 20,000 gas。

  • 修改 storage 的值将花费 5,000 gas 。

  • 清理存储插槽时(即将非零字节设置为零),将退还一定量的 gas 。

  • storage 共有 2^256 个插槽,每个插槽 32 个字节数据按声明顺序依次存储,数据将会从每个插槽的右边开始存储,如果相邻变量适合单个 32 字节,然后它们被打包到同一个插槽中否则将会启用新的插槽来存储。

  • storage 中的数组的存储方式就比较独特了,首先,solidity 中的数组分为两种:

    • a.定长数组(长度固定):
      定长数组中的每个元素都会有一个独立的插槽来存储。以一个含有三个 uint64 元素的定长数组为例,下图可以清楚的看出其存储方式:
    • b.变长数组(长度随元素的数量而改变):
      变长数组的存储方式就很奇特,在遇到变长数组时,会先启用一个新的插槽 slotA 用来存储数组的长度,其数据存储在另外的编号为 slotV 的插槽中。slotA 表示变长数组声明的位置,用 length 表示变长数组的长度,用 slotV 表示变长数组数据存储的位置,用 value 表示变长数组某个数据的值,用 index 表示 value 对应的索引下标,则
      length = sload(slotA)
      slotV = keccak256(slotA) + index
      value = sload(slotV)

      变长数组在编译期间无法知道数组的长度,没办法提前预留存储空间,所以 Solidity 就用 slotA 位置存储了变长数组的长度。
      我们写一个简单的例子来验证上面描述的变长数组的存储方式:

      pragma solidity ^0.8.0;
      contract haha{
        uint[] user;
          function addUser(uint a) public returns (bytes memory){
              user.push(a);
              return abi.encode(user);
          }
      }

      部署这个合约后调用 addUser 函数并传入参数 a = 998,debug 后可以看出变长数组的存储方式:

      • 其中第一个插槽为(这里存储的是变长数组的长度):
        0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
        这个值等于:
        sha3("0x0000000000000000000000000000000000000000000000000000000000000000")
        key = 0 这是当前插槽的编号
        value = 1 这说明变长数组 user[] 中只有一条数据也就是数组长度为 1 ;
      • 第二个插槽为(这里存储的是变长数组中的数据):
        0x510e4e770828ddbf7f7b00ab00a9f6adaf81c0dc9cc85f1f8249c256942d61d9
        这个值等于:
        sha3("0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563")
        插槽编号为:
        key=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
        这个值等于:
        sha3("0x0000000000000000000000000000000000000000000000000000000000000000")+0
        插槽中存储的数据为:
        value=0x00000000000000000000000000000000000000000000000000000000000003e6
        也就是 16 进制表示的 998 ,也就是我们传入的 a 的值。
        为了更准确的验证我们再调用一次 addUser 函数并传入 a=999 可以得到下面的结果:

        这里我们可以看到新的插槽为:
        0x6c13d8c1c5df666ea9ca2a428504a3776c8ca01021c3a1524ca7d765f600979a
        这个值等于:
        sha3("0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564")
        插槽编号为: key=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564
        这个值等于:
        sha3("0x0000000000000000000000000000000000000000000000000000000000000000")+1
        插槽中的存储数据为:
        value=0x00000000000000000000000000000000000000000000000000000000000003e7
        这个值就是 16 进制表示的 999 也就是我们刚刚调用 addUser 函数传入的 a 的值。
        通过上面的例子应该可以大致理解变长数组的存储方式了。

2. memory(内存)

  • memory 是一个字节数组,其插槽大小为 256 位(32 个字节)。数据仅在函数执行期间存储,执行完之后,将会被删除。它们不会保存到区块链中。
  • 读或写一个字节(256 位)需要 3 gas 。
  • 为了避免给矿工带来太多工作,在进行 22 次读写操作后,之后的读写成本开始上升。

3. calldata(调用数据)

  • calldata 是一个不可修改的,非持久性的区域,用于存储函数参数,并且其行为基本上类似于 memory。
  • 调用外部函数的参数需要 calldata,也可用于其他变量。
  • 它避免了复制,并确保了数据不能被修改。
  • 带有 calldata 数据位置的数组和结构体也可以从函数中返回,但是不可以为这种类型赋值。

可见性关键字

了解了 solidity 中的三种存储方式后我们再来了解一下合约中的四种可见性关键字:在 solidity 中,有四种可见性关键字:external,public,internal 和 private。默认时函数可见性为 public。对状态变量而言,除了不能用 external 来定义,其它三个都可以来定义变量,状态变量默认的可见性为 internal。

1. external 关键字

external 定义的外部函数可以被其它合约调用。用 external 修饰的外部函数 function() 不能作为内部函数直接调用,也就是说 function() 的调用方式必须用 this.function() 。

2. public 关键字

public 定义的函数可以被内部函数或外部消息调用。对用 public 定义的状态变量,系统会自动生成一个 getter 函数。

3. internal 用关键字

internal 定义的函数和状态变量只能在(当前合约或当前合约派生的合约)内部进行访问。

4. private 关键字

private 定义的函数和状态变量只对定义它的合约可见,该合约派生的合约都不能调用和访问该函数及状态变量。

综上可知,合约中修饰变量存储的关键字仅仅限制了其调用的范围,并没有限制其是否可读。所以我们今天就来带大家了解如何读取合约中的所有数据。

漏洞示例

这次我们的目标合约是部署在 Ropsten 上的一个合约。
合约地址:
0x3505a02BCDFbb225988161a95528bfDb279faD6b
链接:
https://ropsten.etherscan.io/address/0x3505a02BCDFbb225988161a95528bfDb279faD6b#code
这里我也给大家把合约源码展示出来:

contract Vault {
    uint256 public count = 123;
    address public owner = msg.sender;
    bool public isTrue = true;
    uint16 public u16 = 31;
    bytes32 private password;
    uint256 public constant someConst = 123;
    bytes32[3] public data;
    struct User {
        uint256 id;
        bytes32 password;
    }
    User[] private users;
    mapping(uint256 => User) private idToUser;

    constructor(bytes32 _password) {
        password = _password;
    }

    function addUser(bytes32 _password) public {
        User memory user = User({id: users.length, password: _password});
        users.push(user);
        idToUser[user.id] = user;
    }

    function getArrayLocation(
        uint256 slot,
        uint256 index,
        uint256 elementSize
    ) public pure returns (uint256) {
        return
            uint256(keccak256(abi.encodePacked(slot))) + (index * elementSize);
    }

    function getMapLocation(uint256 slot, uint256 key)
        public
        pure
        returns (uint256)
    {
        return uint256(keccak256(abi.encodePacked(key, slot)));
    }
}

漏洞分析

由上面的合约代码我们可以看到,Vault 合约将用户的用户名和密码这样的敏感数据记录在了合约中,由前置知识中我们可以了解到,合约中修饰变量的关键字仅限制其调用范围,这也就间接证明了合约中的数据均是公开的,可任意读取的,将敏感数据记录在合约中是不安全的。

读取数据

下面我们就带大家来读取这个合约中的数据。首先我们先看 slot0 中的数据:
由合约中可以看到 slot0 中只存储了一个 uint 类型的数据,我们读取出来看一下:
我这里使用 Web3.py 取得数据
首先写好程序

运行后得到

我们使用进制转换器转换一下

这里我们就成功的去到了合约中的第一个插槽 slot0 中存储的 uint 类型的变量 count=123 ,下面我们继续:
slot1 中存储三个变量:u16, isTrue, owner


从右往左依次为

owner = f36467c4e023c355026066b8dc51456e7b791d99
isTrue = 01 = true
u16 = 1f = 31

slot2 中就存储着私有变量 password 我们读取看看


slot 3, 4, 5 中存储着定长数组中的三个元素


slot6 中存储着变长数组的长度


我们从合约代码中可以看到用户的 id 和 password 是由键值对的形式存储的,下面我们来读取两个用户的 id 和 password:

  • user1

  • user2

好了,这里我们就成功的将合约中的所有数据读取完成,现在大家应该都能得出一个结论:合约中的私有数据也是可以读取的。

修复建议

  1. 作为开发者
    不要将任何敏感数据存放在合约中,因为合约中的任何数据都可被读取。
  2. 作为审计者
    在审计过程中应当注意合约中是否存在敏感数据,例如:秘钥,游戏通关口令等。

参考文献

本期讲解的知识有点偏底层,可以参考以下文章帮助你更好地理解:

多签钱包的工作原理与使用方式

多签名钱包常被缩写为 “Multisig wallet”,多签钱包最大的特点是需由多个私钥持有者的授权才能进行钱包交易。本文会为你详细介绍什么是多签钱包、多签钱包的应用场景、多签钱包的工作原理及Gnosis Safe 多签钱包的使用流程

什么是多签钱包?

多签名钱包常被缩写为 “Multisig wallet”,与多签钱包对应的是单签钱包,我们要往区块链上发送一笔转账交易,需要去用钱包去做一个签名,我们自己签好名把交易发送出去,交易执行成功转账就成功,这就是典型的单签钱包,也是我们平时使用最多的钱包。

多签钱包,顾名思义,就是需要多个人去签名执行某个操作的钱包。使用多签钱包进行转账,往往需要 >= 1 个人去签名发送交易之后,转账操作才真正完成。使用多签钱包时,我们可以指定 m/n 的签名模式,就是 n 个人里面有 m 个人签名即可完成操作。可以根据自己的需求设置多签规则,例如:

  • 1/2多签模式:两个互相信任的朋友或自己的两个钱包,可以凭各自的私钥独立发起交易(类似于合伙账户)。
  • 2/2多签模式:金库中的资金需要2个管理员均同意才能动用这笔资金(需要两个私钥才能转移资金)。
  • 2/3多签模式:三个合伙人共同管理资金,为了规避私钥丢失的风险,其中两个私钥签名就可以转移资金。

当然,还有1/3多签、3/6多签、5/8多签不同规则的多签方案,规则是按需的。多签钱包最大的特点是需由多个私钥持有者的授权才能进行钱包交易。我们讲了这么多签名规则,那多签钱包的应用场景是什么呢?

多签钱包的应用场景

多签钱包最常见的应用场景是需求强安全性的个人,以及管理公共资产的投资机构、交易所以及项目方。

1. 资金安全

资金的安全也可以理解为私钥的安全,有一些常见的方案如使用硬件钱包来防止私钥泄露,使用助记词密盒来防止私钥遗忘等等,但依然存在“单点故障”的问题。

在单签钱包中,加密资产的所有权和管理员是在单人手中,一但私钥泄露或遗忘就意味着失去了对钱包的控制权,与之关联的加密资产将完全丢失。而多签钱包的存在,就很大程度上降低了资产损失的风险。以2/3多签模式为例,在全部的3个私钥中,只要有2个私钥完成签名授权就能完成加密资产的转移。

对于个人而言,可以通过一个多签钱包,关联多个钱包地址,分布在多处(类似异地多活、同城多机房),一个放在MetaMask浏览器扩展、一个安装在手机钱包App、一个在冷钱包,需要转移加密资产时只需要用其中的两个钱包共同签名即可。当然为了方便的话,可以使用1/3多签模式,这就类似于把同一个私钥记在三个助记词卡上放在多处一样,但这种方式仅仅是降低了密钥丢失的风险。

2. 资金共管

很多DeFi 协议/DAO 组织/区块链团队其实都有自己的金库,金库里的资产是不能由任何一个人直接动用的,每次动用都要经过多数人的同意或社区投票。这时使用多签钱包来保存金库资产是再合适不过了。

3. 多签操作

在目前这个发展阶段,很多去中心化协议其实都是有个管理员权限的,这个管理员权限往往可以更改协议的某些关键参数。行业普遍做法是把这个管理员权限交给一个多签钱包或时间锁,当需要更改参数时,需要多个人共同签署相关操作。

多签钱包的工作原理

上文中提到的n/m多签方式,多个私钥对应一个多签钱包,这个多签钱包是如何实现的呢?

我们常说的多签主要针对的是比特币和以太坊ERC-20标准代币。在比特币中有2种类型的地址,1开头的是P2PKH表示个人地址,3开头的是P2SH一般表示一个多签地址。普通的比特币地址是由公钥做哈希后得到的,而多重签名地址基于脚本哈希,所以能够实现复杂的交易逻辑。所以在原生上比特币就支持多签。而以太坊原生并不支持多签地址,通常需要依靠智能合约来实现这一机制。因此,比特币多签钱包技术上要更容易实现,也更常见。
在以太坊中,多签钱包往往是一个智能合约。我们以 Gnosis 的一个多签钱包地址的合约为例进行简要阐述,图中截取了核心流程的主要代码,详细可查看:0xcafE1A77e84698c83CA8931F54A755176eF75f2C (如果非开发者可以略过本章节继续往下看)

1. 构造多签合约的调用者权限

图中的 constructor 构造方法是合约创建时触发调用的,通过传入 onwers 参数传入授权的多个钱包地址,以及 required 参数表示最少签名人数。
即以M/N多签模式为例,N表示 owners.length ,N表示 required

2. 提交多签钱包交易申请

图中 submitTransaction 方法的作用是多签名人任一一方提交交易申请,返回一个交易号(transactionId 后面会用到)。参数 destination 是接受人的钱包地址,value 为转出的 ether 数量(以 wei 为单位),data 是该交易的数据。

前两个参数比较好理解,向某地址转出多少资产,data 参数可以传入任意数组来实现任意功能,比如如果转出ETH那么此参数是[] (空),如果转出ERC20代码(如USDT),则此参数是ERC20 transfer 方法的哈希和参数 ([0]:xxxxx [1]:xxxxx)。

3. 其余签名人对交易确认

图中的 confirmTransaction 方法的作用是其他参与签名的人发起确认以表示对某个交易执行的认可。参数就是 submitTransaction 流程里提交交易申请时产生的交易号。当然参与者也可以拒绝认可,还有一个 revokeConfirmation 方法来提供拒绝的行为在图中没有体现,可以去合约代码里查看。

4. 正式执行交易操作

当确认的人数达到最低(required)要求,executeTransaction 的内部逻辑将被触发,从而执行第一步用户所提交的逻辑。当 executeTransaction 内部逻辑被触发,即完成了多签合约的真正调用,如上图所述,value 和 data 可以控制多签执行任意逻辑(转移 ether 或 ERC20 代币等)。

常用的多签钱包有哪些?

这一章节并非做多签钱包的推荐,我只罗列出我用过的两个多签钱包,并通过使用流程的介绍来辅助理解合约代码中的逻辑。

Gnosis Safe 是一款为钱包提供多签功能的智能合约。使用不同加密钱包的用户可以在Gnosis Safe 网页端创建一个多签账户,将需要共管的资产存入这一多签账户并进行相应的多签交易,Gnosis Safe本身并不掌握任何私钥。目前,Gnosis Safe支持以太坊网络、币安智能链网络以及Polygon网络等12个网络的多签,支持币种包括ETH、ERC20标准代币以及ERC721标准代币等。

Gnosis Safe的优点是多签参与者不用再额外注册统一的多签钱包,使用现有的加密钱包就可以完成多签步骤;缺点是该智能合约直接部署在区块链上,每一次交互都是链上的一次交易,即创建钱包、多签过程中的每一次签名授权都要支付一笔Gas费用。每次支付的费用都会根据当时的网络情况、参与人数、交易复杂程度发生变化。

接下来我以 Gnosis Safe 钱包来演示一下多签钱包的使用流程,作为有多签钱包需求读者的入门教程,使用过多签的读者可以略过。

1. 创建多签钱包

创建多签钱包的过程很简单,输入钱包名称和参与签名的钱包地址即可,我在 Polygon 网络(以太坊侧链,Gas比较便宜)演示 2/2 签名模式,即多签钱包对应2个签名者,且两个签名者均同意才能转出资产。

对于新的用户需要额外说明的是,在我们创建普通钱包(或叫外部账户,以太坊的账户分为外部账户和合约账户)时,只是在钱包的客户端通过一定的加密算法在客户端本地生成的钱包地址(没有上链,只有产生了交易才会在链上有了关联),所以普通钱包是不需要支持Gas费的,而多签钱包本质上是一个部署在链上的智能合约,而部署合约就像发起转账一样会产生一笔交易,所以需要支出Gas费用来奖励旷工确认这笔交易。

创建多签合约时的交易:https://polygonscan.com/tx/0x78dee97d40ea5e45c4b2d08d878694d075be76bd34dfb01508afae9b9bf34f73

注意:从创建钱包,到付款和收款,均重点关注选择的网络(本示例为 Polygon 网络),一定注意!!



2. 通过多签钱包收款

这一步不做过多说明,多签钱包的合约和普通钱包一样具有收款的能力,只是在转出机制不同:

  • 普通钱包地址是通过在钱包客户端本地对交易进行签名然后广播上链
  • 合约地址是需要触发合约公开的方法通过合约执行交易行为



为了演示用多签钱包付款,这里先往里转入小额的 $MATIC:
https://polygonscan.com/tx/0xff65a58854d42610dc531b9a0f0efff22ca7e97def6e49f9eccd1011fa0c569b

3. 通过多签钱包付款

这是比较重要的步骤,感兴趣的读者可以结合上面的代码示例来理解

i. 任意签名人发起一笔转账申请

这一步对应到合约里的 submitTransaction 方法,发起一笔交易申请,但资产没有真正开始转移,需要其他的参与者进行确认这笔交易申请。
这一步的操作是 签名人A 在浏览器通过 MetaMask 钱包登录 Gnosis Safe,选择对应的网络,发起付款操作(填入转出的钱包地址和金额)


ii. 其他参与者对这笔转账申请进行确认

这一步会演示两个流程:参与确认、执行转账,对应到合约代码里的就是 confirmTransaction 和 executeTransaction 方法。

以这个 2/2 多签模式,上一步签名人A发起了一笔申请, 此时签名人B 在另外一个浏览器(模拟两个不同的参与人)同样通过 MetaMask 钱包登录 Gnosis Safe,会在交易中看到签名人A发起的待确认的交易,然后执行确认交易。此时因为已经两个人参与,达到了最少参与人的要求,所以参与人B在确认时就会触发真正的转账行为。

此时的确认操作即调用了合约发起转账(tx.destination.call.value(tx.value)(tx.data)),执行转账的方法(调用合约的写方法)会产生Gas的消耗,即最后的确认者需要支付本次交易的手续费(是不是有点冤)。

对应的链上交易:https://polygonscan.com/tx/0x484f32a722dec98bd8ca9ac508bc8c846a663a1ac5500fbbde38d53a13d1f71d



以上,就是多签钱包的介绍、使用场景、工作原理、操作流程的全部内容,感谢阅读。如果有问题交流,可以关注并私信我:微信(jingwentian)、Twitter(@0xDaotian)、微信公众号(北极之野)、Substack邮件订阅(文叔白话WEB3)。
转载:https://learnblockchain.cn/article/4077

搜索