您正在查看: Ethereum-优秀转载 分类下的文章

合约安全 - 选择器碰撞

这一讲,我们将介绍选择器碰撞攻击,它是导致跨链桥 Poly Network 被黑的原因之一。在2021年8月,Poly Network在ETH,BSC,和Polygon上的跨链桥合约被盗,损失高达6.11亿美元。这是2021年最大的区块链黑客事件,也是历史被盗金额榜单上第2名,仅次于 Ronin 桥黑客事件。

选择器碰撞

以太坊智能合约中,函数选择器是函数签名 "()" 的哈希值的前4个字节(8位十六进制)。当用户调用合约的函数时,calldata的前4字节就是目标函数的选择器,决定了调用哪个函数。如果你不了解它,可以阅读WTF Solidity极简教程第29讲:函数选择器

由于函数选择器只有4字节,非常短,很容易被碰撞出来:即我们很容易找到两个不同的函数,但是他们有着相同的函数选择器。比如transferFrom(address,address,uint256)和gasprice_bit_ether(int128)有着相同的选择器:0x23b872dd。当然你也可以写个脚本暴力破解。

大家可以用这两个网站来查同一个选择器对应的不同函数:

  1. https://www.4byte.directory/
  2. https://sig.eth.samczsun.com/

你也可以使用下面的Power Clash工具进行暴力破解:

  1. PowerClash: https://github.com/AmazingAng/power-clash

相比之下,钱包的公钥有256字节,被碰撞出来的概率几乎为0,非常安全。

漏洞合约例子

漏洞合约

下面我们来看一下有漏洞的合约例子。SelectorClash合约有1个状态变量 solved,初始化为false,攻击者需要将它改为true。合约主要有2个函数,函数名沿用自 Poly Network 漏洞合约。

  1. putCurEpochConPubKeyBytes() :攻击者调用这个函数后,就可以将solved改为true,完成攻击。但是这个函数检查msg.sender == address(this),因此调用者必须为合约本身,我们需要看下其他函数。

  2. executeCrossChainTx() :通过它可以调用合约内的函数,但是函数参数的类型和目标函数不太一样:目标函数的参数为(bytes),而这里调用的函数参数为(bytes,bytes,uint64)。

    contract SelectorClash {
     bool public solved; // 攻击是否成功
    
     // 攻击者需要调用这个函数,但是调用者 msg.sender 必须是本合约。
     function putCurEpochConPubKeyBytes(bytes memory _bytes) public {
         require(msg.sender == address(this), "Not Owner");
         solved = true;
     }
    
     // 有漏洞,攻击者可以通过改变 _method 变量碰撞函数选择器,调用目标函数并完成攻击。
     function executeCrossChainTx(bytes memory _method, bytes memory _bytes, bytes memory _bytes1, uint64 _num) public returns(bool success){
         (success, ) = address(this).call(abi.encodePacked(bytes4(keccak256(abi.encodePacked(_method, "(bytes,bytes,uint64)"))), abi.encode(_bytes, _bytes1, _num)));
     }
    }

攻击方法

我们的目标是利用executeCrossChainTx()函数调用合约中的putCurEpochConPubKeyBytes(),目标函数的选择器为:0x41973cd9。观察到executeCrossChainTx()中是利用_method参数和"(bytes,bytes,uint64)"作为函数签名计算的选择器。因此,我们只需要选择恰当的_method,让这里算出的选择器等于0x41973cd9,通过选择器碰撞调用目标函数。

Poly Network黑客事件中,黑客碰撞出的_method为 f1121318093,即f1121318093(bytes,bytes,uint64)的哈希前4位也是0x41973cd9,可以成功的调用函数。接下来我们要做的就是将0x41973cd9转换为bytes类型:0x6631313231333138303933,然后作为参数输入到executeCrossChainTx()中。executeCrossChainTx()函数另3个参数不重要,都填 0x 就可以。

Remix演示

  1. 部署SelectorClash合约。
  2. 调用executeCrossChainTx(),参数填0x6631313231333138303933,0x,0x,0x,发起攻击。
  3. 查看solved变量的值,被修改为ture,攻击成功。

总结

这一讲,我们介绍了选择器碰撞攻击,它是导致跨链桥 Poly Network 被黑 6.1 亿美金的的原因之一。这个攻击告诉了我们:

  • 函数选择器很容易被碰撞,即使改变参数类型,依然能构造出具有相同选择器的函数。
  • 管理好合约函数的权限,确保拥有特殊权限的合约的函数不能被用户调用。

转载:https://mirror.xyz/wtfacademy.eth/5rwcsBZzphdlKZj4MoIpn8aqwQ1MzQ8qy50ZEGNU_HU

接收ETH receive和fallback

回调函数

Solidity支持两种特殊的回调函数,receive()和fallback(),他们主要在两种情况下被使用:

  • 接收ETH
  • 处理合约中不存在的函数调用(代理合约proxy contract)

我们这一讲主要介绍接收ETH的情况。

接收ETH函数 receive

receive()只用于处理接收ETH。一个合约最多有一个receive()函数,声明方式与一般函数不一样,不需要function关键字:receive() external payable { ... }

receive()函数不能有任何的参数,不能返回任何值,必须包含external和payable。

当合约接收ETH的时候,receive()会被触发。receive()最好不要执行太多的逻辑因为如果别人用send和transfer方法发送ETH的话,gas会限制在2300,receive()太复杂可能会触发Out of Gas报错;如果用call就可以自定义gas执行更复杂的逻辑(这三种发送ETH的方法我们之后会讲到)。

我们可以在receive()里发送一个event,例如:

// 定义事件
event Received(address Sender, uint Value);
// 接收ETH时释放Received事件
receive() external payable {
    emit Received(msg.sender, msg.value);
}

有些恶意合约,会在receive()函数嵌入恶意消耗gas的内容,使得一些退款合约不能正常工作:Akutar NFT项目因此被永久锁定了11539 ETH,接近2亿元!因此写包含退款等逻辑的合约时候,一定要注意这种情况。

回退函数 fallback

fallback()函数会在调用合约不存在的函数时被触发。可用于接收ETH,也可以用于代理合约proxy contract。fallback()声明时不需要function关键字,必须由external修饰,一般也会用payable修饰,用于接收ETH:fallback() external payable { ... }。

我们定义一个fallback()函数,被触发时候会释放fallbackCalled事件,并输出msg.sender,msg.value和msg.data:

// fallback
fallback() external payable{
    emit fallbackCalled(msg.sender, msg.value, msg.data);
}

receive和fallback的区别

receive和fallback都能够用于接收ETH,他们触发的规则如下:
触发fallback() 还是 receive()?

           接收ETH
              |
         msg.data是空?
            /  \
          是    否
          /      \
receive()存在?   fallback()
        / \
       是  否
      /     \
receive()   fallback()

简单来说,合约接收ETH时,msg.data为空且存在receive()时,会触发receive();msg.data不为空或不存在receive()时,会触发fallback(),此时fallback()必须为payable。

receive()和payable fallback()均不存在的时候,向合约发送ETH将会报错。

总结

这一讲,我介绍了Solidity中的两种特殊函数,receive()和fallback(),他们主要在两种情况下被使用,他们主要用于处理接收ETH和代理合约proxy contract。

转载:https://mirror.xyz/wtfacademy.eth/EroVZqHW1lfJFai3umiu4tb9r1ZbDVPOYC-puaZklAw

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

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

了解如何访问合约中的私有数据(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