第8节:世界杯竞猜(multiSigWallet)

小白入门:https://github.com/dukedaily/solidity-expert ,欢迎star转发,文末加V入群。

职场进阶: https://dukeweb3.com

概述

所谓多签钱包是一种数字钱包,其特点是一笔交易需要被多个私钥持有者(多签人)授权后才能执行:例如钱包由3个多签人管理,每笔交易需要至少2人签名授权。多签钱包可以防止单点故障(私钥丢失,单人作恶),更加去中心化,更加安全,被很多DAO采用。

Genesis Safe

gnosis是当下最出名的以太坊多签钱包,代码是开源的,在测试网也可以体验,我这里整理了它的基本逻辑,我们也会在本文中尝试写一个简单版的gnosis,介绍一下其核心原理。

操作流程

点击create new safe,并选择goerli网络

image-20221128193944655

填写名字:test

image-20221128194020945

填写三个地址,并设置策略为:2/3,即三个人持有私钥,有2个人签名即通过。

image-20221128194209626

最终效果:(手动向地址中转入了token),后续任何转账、合约交互时,点击NewTransaction操作即可,此时需要至少两个人授权才能发起交易。

image-20221128195050911

创建流程分析

  • 与我们交互的合约是一个工厂:0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2,点击查看
    • createProxyWithNonce(_singleton, initializer, saltNonce)
      • _singleton,这是一个模板:0x3E5c63644E683549055b9Be8653de26E0B4CD36E,这个其实是多签的implement;
      • initializer: 包含两个owner的编码信息;(创建时仅指定了2个owner)
      • saltNonce:salt值
  • 上函数内部会创建一个:proxy,通过proxyCreation事件抛出来,这个proxy就是我们最终想要获取的多签地址:0xA752C9e8036dd060724707Be24E505b5f81588FE,点击查看
  • 这个proxy最终调用的业务逻辑就是上面的的_singleton:0x3E5c63644E683549055b9Be8653de26E0B4CD36E

gnosis小结

一共有三个合约:

  • 代理合约工厂:GnosisSafeProxyFactory(创建的时候,与这个交互)
  • 代理合约:GnosisSafeProxy(最终我们与这个交互)
  • 业务实现合约:GnosisSafeL2(实际业务在这里实现)

image-20221128200420912

简单多签钱包

分析

接下来我们将实现一个简化板的多签钱包:MultiSigWallet,我们将不支持owner动态增减,实现步骤为:

  1. 确定多签策略,为了简化操作,我们不支持动态增减,多签策略设置为:2/3;
  2. 创建一笔待授权的交易,包含以下内容:
    • to:目标合约;
    • value:交易发送的以太坊数量;
    • data:calldata,包含调用函数的选择器和参数;
    • nonce:初始为0,随着多签合约每笔成功执行的交易递增的值,可以防止签名重放攻击;
    • chainid:链id,防止不同链的签名重放攻击。
  3. 获取链下签名,签名的原始内容为上述待授权交易,我们最终其实是对交易对hash(经过ERC191处理的)进行签名,并将所有签名拼装在一起,传递给合约,并在内部对签名进行逐个校验。

两个事件

MultisigWallet合约有2个事件,ExecutionSuccessExecutionFailure,分别在交易成功和失败时释放,参数为交易哈希。

event ExecutionSuccess(bytes32 txHash);    // 交易成功事件
event ExecutionFailure(bytes32 txHash);    // 交易失败事件

五个状态变量

  1. owners:多签持有人数组
  2. isOwneraddress => bool的映射,记录一个地址是否为多签持有人。
  3. ownerCount:多签持有人数量
  4. threshold:多签执行门槛,交易至少有n个多签人签名才能被执行。
  5. nonce:初始为0,随着多签合约每笔成功执行的交易递增的值,可以防止签名重放攻击。
    address[] public owners;                   // 多签持有人数组 
    mapping(address => bool) public isOwner;   // 记录一个地址是否为多签持有人
    uint256 public ownerCount;                 // 多签持有人数量
    uint256 public threshold;                  // 多签执行门槛,交易至少有n个多签人签名才能被执行。
    uint256 public nonce;                      // nonce,防止签名重放攻击

六个函数

  1. 构造函数:调用_setupOwners(),初始化和多签持有人和执行门槛相关的变量。

    // 构造函数,初始化owners, isOwner, ownerCount, threshold 
    constructor(address[] memory _owners, uint256 _threshold) {
        _setupOwners(_owners, _threshold);
    }
    
  2. _setupOwners():在合约部署时被构造函数调用,初始化ownersisOwnerownerCountthreshold状态变量。传入的参数中,执行门槛需大于等于1且小于等于多签人数;多签地址不能为0地址且不能重复。

    function _setupOwners(address[] memory _owners, uint256 _threshold) internal {
        // threshold没被初始化过
        require(threshold == 0, "001");
        // 多签执行门槛 小于 多签人数
        require(_threshold <= _owners.length, "002");
        // 多签执行门槛至少为1
        require(_threshold >= 1, "003");
    
        for (uint256 i = 0; i < _owners.length; i++) {
            address owner = _owners[i];
            // 多签人不能为0地址,本合约地址,不能重复
            require(owner != address(0) && owner != address(this) && !isOwner[owner], "004");
            owners.push(owner);
            isOwner[owner] = true;
        }
        ownerCount = _owners.length;
        threshold = _threshold;
    }
    
  3. execTransaction():在收集足够的多签签名后,验证签名并执行交易。传入的参数为目标地址to,发送的以太坊数额value,数据data,以及打包签名signatures。打包签名就是将收集的多签人对交易哈希的签名,按多签持有人地址从小到大顺序,打包到一个[bytes]数据中。这一步调用了encodeTransactionData()编码交易,调用了checkSignatures()检验签名是否有效、数量是否达到执行门槛。

    function execTransaction(
        address to,
        uint256 value,
        bytes memory data,
        bytes memory signatures
    ) public payable virtual returns (bool success) {
        // 编码交易数据,计算哈希
        bytes32 txHash = encodeTransactionData(to, value, data, nonce, block.chainid);
        nonce++;
        // 检查签名
        checkSignatures(txHash, signatures);
        // 利用call执行交易,并获取交易结果
        (success, ) = to.call{value: value}(data);
        require(success , "005");
        if (success) emit ExecutionSuccess(txHash);
        else emit ExecutionFailure(txHash);
    }
    
  4. checkSignatures():检查签名和交易数据的哈希是否对应,数量是否达到门槛,若否,交易会revert。单个签名长度为65字节,因此打包签名的长度要长于threshold * 65。调用了signatureSplit()分离出单个签名。这个函数的大致思路:

    • 用ecdsa获取签名地址;
    • 利用 currentOwner > lastOwner 确定签名来自不同多签(多签地址递增);
    • 利用isOwner[currentOwner]确定签名者为多签持有人。
    function checkSignatures(bytes32 dataHash, bytes memory signatures) public view {
        // 读取多签执行门槛
        uint256 _threshold = threshold;
        require(_threshold > 0, "006");
    
        // 检查签名长度足够长
        require(signatures.length >= _threshold * 65, "007");
    
        // 通过一个循环,检查收集的签名是否有效
        // 大概思路:
        // 1. 用ecdsa先验证签名是否有效
        // 2. 利用 currentOwner > lastOwner 确定签名来自不同多签(多签地址递增)
        // 3. 利用 isOwner[currentOwner] 确定签名者为多签持有人
        address lastOwner = address(0); 
        address currentOwner;
        uint8 v;
        bytes32 r;
        bytes32 s;
        uint256 i;
        for (i = 0; i < _threshold; i++) {
            (v, r, s) = signatureSplit(signatures, i);
            // 利用ecrecover检查签名是否有效
            currentOwner = ecrecover(keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)), v, r, s);
            require(currentOwner > lastOwner && isOwner[currentOwner], "008");
            lastOwner = currentOwner;
        }
    }
    
  5. signatureSplit():将单个签名从打包的签名分离出来,参数分别为打包签名signatures和要读取的签名位置pos。利用了内联汇编,将签名的rs,和v三个值分离出来。

    function signatureSplit(bytes memory signatures, uint256 pos)
        internal
        pure
        returns (
            uint8 v,
            bytes32 r,
            bytes32 s
        )
    {
        // 签名的格式:{bytes32 r}{bytes32 s}{uint8 v}
        assembly {
            let signaturePos := mul(0x41, pos)
            r := mload(add(signatures, add(signaturePos, 0x20)))
            s := mload(add(signatures, add(signaturePos, 0x40)))
            v := and(mload(add(signatures, add(signaturePos, 0x41))), 0xff)
        }
    }
    
  6. encodeTransactionData():将交易数据打包并计算哈希,利用了abi.encode()keccak256()函数。这个函数可以计算出一个交易的哈希,然后在链下让多签人签名并收集,再调用execTransaction()函数执行。

    function encodeTransactionData(
        address to,
        uint256 value,
        bytes memory data,
        uint256 _nonce,
        uint256 chainid
    ) public pure returns (bytes32) {
        bytes32 safeTxHash =
            keccak256(
                abi.encode(
                    to,
                    value,
                    keccak256(data),
                    _nonce,
                    chainid
                )
            );
        return safeTxHash;
    }
    

完整代码

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

/// 基于签名的多签钱包,由gnosis safe合约简化而来,教学使用。
contract MultisigWallet {
    event ExecutionSuccess(bytes32 txHash);    // 交易成功事件
    event ExecutionFailure(bytes32 txHash);    // 交易失败事件

    address[] public owners;                   // 多签持有人数组 
    mapping(address => bool) public isOwner;   // 记录一个地址是否为多签
    uint256 public ownerCount;                 // 多签持有人数量
    uint256 public threshold;                  // 多签执行门槛,交易至少有n个多签人签名才能被执行。
    uint256 public nonce;                      // nonce,防止签名重放攻击

    receive() external payable {}

    // 构造函数,初始化owners, isOwner, ownerCount, threshold 
    constructor(address[] memory _owners,uint256 _threshold) {
        _setupOwners(_owners, _threshold);
    }

    /// @dev 初始化owners, isOwner, ownerCount,threshold 
    /// @param _owners: 多签持有人数组
    /// @param _threshold: 多签执行门槛,至少有几个多签人签署了交易
    function _setupOwners(address[] memory _owners, uint256 _threshold) internal {
        // threshold没被初始化过
        require(threshold == 0, "001");
        // 多签执行门槛 小于 多签人数
        require(_threshold <= _owners.length, "002");
        // 多签执行门槛至少为1
        require(_threshold >= 1, "003");

        for (uint256 i = 0; i < _owners.length; i++) {
            address owner = _owners[i];
            // 多签人不能为0地址,本合约地址,不能重复
            require(owner != address(0) && owner != address(this) && !isOwner[owner], "004");
            owners.push(owner);
            isOwner[owner] = true;
        }
        ownerCount = _owners.length;
        threshold = _threshold;
    }

    /// @dev 在收集足够的多签签名后,执行交易
    /// @param to 目标合约地址
    /// @param value msg.value,支付的以太坊
    /// @param data calldata
    /// @param signatures 打包的签名,对应的多签地址由小到达,方便检查。 ({bytes32 r}{bytes32 s}{uint8 v}) (第一个多签的签名, 第二个多签的签名 ... )
    function execTransaction(
        address to,
        uint256 value,
        bytes memory data,
        bytes memory signatures
    ) public payable virtual returns (bool success) {
        // 编码交易数据,计算哈希
        bytes32 txHash = encodeTransactionData(to, value, data, nonce, block.chainid);
        nonce++;  // 增加nonce
        checkSignatures(txHash, signatures); // 检查签名
        // 利用call执行交易,并获取交易结果
        (success, ) = to.call{value: value}(data);
        require(success , "005");
        if (success) emit ExecutionSuccess(txHash);
        else emit ExecutionFailure(txHash);
    }

    /**
     * @dev 检查签名和交易数据是否对应。如果是无效签名,交易会revert
     * @param dataHash 交易数据哈希
     * @param signatures 几个多签签名打包在一起
     */
    function checkSignatures(
        bytes32 dataHash,
        bytes memory signatures
    ) public view {
        // 读取多签执行门槛
        uint256 _threshold = threshold;
        require(_threshold > 0, "006");

        // 检查签名长度足够长
        require(signatures.length >= _threshold * 65, "007");

        // 通过一个循环,检查收集的签名是否有效
        // 大概思路:
        // 1. 用ecdsa先验证签名是否有效
        // 2. 利用 currentOwner > lastOwner 确定签名来自不同多签(多签地址递增)
        // 3. 利用 isOwner[currentOwner] 确定签名者为多签持有人
        address lastOwner = address(0); 
        address currentOwner;
        uint8 v;
        bytes32 r;
        bytes32 s;
        uint256 i;
        for (i = 0; i < _threshold; i++) {
            (v, r, s) = signatureSplit(signatures, i);
            // 利用ecrecover检查签名是否有效
            currentOwner = ecrecover(keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)), v, r, s);
            require(currentOwner > lastOwner && isOwner[currentOwner], "008");
            lastOwner = currentOwner;
        }
    }

    /// 将单个签名从打包的签名分离出来
    /// @param signatures 打包的多签
    /// @param pos 要读取的多签index.
    function signatureSplit(bytes memory signatures, uint256 pos)
        internal
        pure
        returns (
            uint8 v,
            bytes32 r,
            bytes32 s
        )
    {
        // 签名的格式:{bytes32 r}{bytes32 s}{uint8 v}
        assembly {
            let signaturePos := mul(0x41, pos)
            r := mload(add(signatures, add(signaturePos, 0x20)))
            s := mload(add(signatures, add(signaturePos, 0x40)))
            v := and(mload(add(signatures, add(signaturePos, 0x41))), 0xff)
        }
    }

    /// @dev 编码交易数据
    /// @param to 目标合约地址
    /// @param value msg.value,支付的以太坊
    /// @param data calldata
    /// @param _nonce 交易的nonce.
    /// @param chainid 链id
    /// @return 交易哈希bytes.
    function encodeTransactionData(
        address to,
        uint256 value,
        bytes memory data,
        uint256 _nonce,
        uint256 chainid
    ) public pure returns (bytes32) {
        bytes32 safeTxHash =
            keccak256(
                abi.encode(
                    to,
                    value,
                    keccak256(data),
                    _nonce,
                    chainid
                )
            );
        return safeTxHash;
    }
}

Remix验证

  1. 部署合约,设置为2/3多签,合约地址(goerli):0x511592F1431f6c9018dA27F58dF944acd7Eac472;
Owner1:0xE8191108261f3234f1C2acA52a0D5C11795Aef9E
Owner2:0xC4109e427A149239e6C1E35Bb2eCD0015B6500B8
Owner3:0x572ed8c1Aa486e6a016A7178E41e9Fc1E59CAe63

["0xE8191108261f3234f1C2acA52a0D5C11795Aef9E","0xC4109e427A149239e6C1E35Bb2eCD0015B6500B8","0x572ed8c1Aa486e6a016A7178E41e9Fc1E59CAe63"], 2
  1. 转账0.1 ETH到多签合约地址(略);
  2. 构建转账交易,将0.1ETH转账到Owner1地址,我们将对这笔交易进行多方签名,满足2/3后,发送转账交易(我们要对这笔交易对hash值进行签名,使用encodeTransactionData完成hash计算),
# 参数
to: 0xE8191108261f3234f1C2acA52a0D5C11795Aef9E
value: 100000000000000000
data: 0x
_nonce: 0
chainid: 5

# 结果(待签名信息)
交易哈希:0xeaa5d901b5c497a883b2d1ad5321ead3a41a7443c50dfb4bc140ccd792c19d1c
  1. 利用Remix中ACCOUNT旁边的笔记图案的按钮进行签名,内容输入上面的交易哈希,获得签名,任选个钱包即可,注意地址顺序要:由小到大,在校验签名时有大小判断。
# 签名地址1: 0x572ed8c1Aa486e6a016A7178E41e9Fc1E59CAe63, 
# 待签名hash内容:0xeaa5d901b5c497a883b2d1ad5321ead3a41a7443c50dfb4bc140ccd792c19d1c
# EIP191 hash:0x8c2479bfdefe6a60aa53abb32917d80e50506fa79cd0dd1a66217c9606a3af24

# signature1:
0xa8529b2e088d3bd2ad05582f810f5d56b0ca412578d7a177ab35b3fadbb10e040009fba2108da2d871f7c704611735a304c5f3c6f4bb34b3fc245f40c029188a1c



# 签名地址2: 0xC4109e427A149239e6C1E35Bb2eCD0015B6500B8
# 待签名hash内容:0xeaa5d901b5c497a883b2d1ad5321ead3a41a7443c50dfb4bc140ccd792c19d1c
# EIP191 hash:0x8c2479bfdefe6a60aa53abb32917d80e50506fa79cd0dd1a66217c9606a3af24

# signature2:
0x88bce1e1d3460d43c95ef6d163f6dddc655bd22b78e4489ce50be18c08b6fe1067521326d216bbee7a637ee9b1a4da3468d6938516cc8fb5803eed0cb3d96f831b


# 拼接后到signature为:(去除0x)
0xa8529b2e088d3bd2ad05582f810f5d56b0ca412578d7a177ab35b3fadbb10e040009fba2108da2d871f7c704611735a304c5f3c6f4bb34b3fc245f40c029188a1c88bce1e1d3460d43c95ef6d163f6dddc655bd22b78e4489ce50be18c08b6fe1067521326d216bbee7a637ee9b1a4da3468d6938516cc8fb5803eed0cb3d96f831b
  1. 调用execTransaction()函数执行交易,将第3步中的交易参数和打包签名作为参数传入。可以看到交易执行成功,ETH被转出多签,点击查看交易详情

image-20221128221805008

转账成功Log如下,ExecutionSuccess成功日志已发出。

image-20221128222019975

浏览器检查,转账成功!

image-20221203095537956

代码集成

我们可以将WorldCup的owner转移给多签包地址,这样后续操作就变成多签管理了,这里就省略了。

总结

本节介绍的多签依赖于前一节的链下签名,多签方案是对合约资产、安全保障最有效的方案,尤其是对DAO组织而言,我们应该广泛使用多签钱包。

加V入群:dukeweb3,公众号:阿杜在新加坡,一起抱团拥抱web3,下期见!

关于作者:国内第一批区块链布道者;2017年开始专注于区块链教育(btc, eth, fabric),目前base新加坡,专注海外defi,dex,元宇宙等业务方向。