solidity学习之多签钱包
什么是多签钱包
多签钱包是一种特殊的钱包,可以添加多个签名用户,在执行交易的时候需要多个持有者同时签名才能提交,比如3个用户的多签钱包需要2个以上的用户同时签名。
这种设计可以有效防止单点故障,保证资产的安全,在dao群中有广泛的应用。
实现逻辑
多签钱包其实是一个智能合约,在合约中存储了多签持有者的信息。
执行交易时,需要先将交易组装成data,计算出hash。拿到交易hash后,多签用户需要分别对hash进行签名,并且将得到的签名拼接成最终的签名作为参数。
将交易data和signature作为参数调用合约中的方法,合约做的工作是:
- 判断签名数量需要大于多签规定的签名数量
- 拆分签名
- 对应每条签名通过
ecrecover还原出签名地址,判断该地址是否存储于合约中 - 满足签名条件后,根据data执行交易
具体实现
ecrecover
ecrecover是solidity内置的验签方法,定义如下:
function ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) public pure returns (address)
其中hash为交易哈希,也就是签名的msg,v,r,s是从签名中拆分得到的,返回值为签名的公钥。
签名拆分
这里使用的是ECDSA标准的签名,
一个标准的 ECDSA 签名是:
bytes32 rbytes32 suint8 v(27 或 28,也可能是 0 或 1)
总共65字节的内容,因此要写一个对signature进行拆分的方法,得到v、r、s
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)
}
}
这里使用了内联汇编的写法,因为涉及到了内存的读取。
signatures是拼接后的签名,pos代表的是当前读取的签名在signatures中是第几个,即索引值。
let signaturePos := mul(0x41, pos)是用来定位当前拆分出签名的偏移量,因为每个签名的长度是65字节,换算成16进制就是0x41,所以偏移量就是0x41*pos。
而r := mload(add(signatures, add(signaturePos, 0x20)))中,mload的作用是从某个内存地址开始,向后读取固定32字节的内容,而add(signatures, add(signaturePos, 0x20))从内部到外部的两个add分别表示:
add(signaturePos, 0x20)表示signaturePos和0x20的偏移量相加,因为bytes类型的前32字节是头部,而非实际数据,所以读取时先偏移到signaturePos,即签名起点,再向后偏移32个字节。add(signatures, pos)指的是从拼接签名的起始位置,偏移到pos的位置
这两个
add,一个是偏移量的相加,一个是位置的移动,但在内联汇编计算中都是使用add方法,因为signatures是一个指针,指向的地址也是用偏移字节数表示的,代表从内存0的位置偏移的数量。
用同样的原理可以取出s和v,要注意的是v的字节数为1,而mload固定取32字节,所以使用and()方法与0xff做了一个与操作,得到最低位1字节的值。
设计好验签逻辑之后,就可以开始写合约内容了。
address[] public owners; // 多签持有人数组
mapping(address => bool) public isOwner; // 记录一个地址是否为多签持有人
uint256 public ownerCount; // 多签持有人数量
uint256 public threshold; // 多签执行门槛,交易至少有n个多签人签名才能被执行。
uint256 public nonce; // nonce,防止签名重放攻击
constructor(
address[] memory _owners,
uint256 _threshold
) {
_setupOwners(_owners, _threshold);
}
/// @param _owners: 多签持有人数组
/// @param _threshold: 多签执行门槛,至少有几个多签人签署了交易
function _setupOwners(address[] memory _owners, uint256 _threshold) internal {
// 多签执行门槛 小于或等于 多签人数
require(_threshold <= _owners.length, "invalid threshold");
// 多签执行门槛至少为1
require(_threshold >= 1, "threshold at least 1");
for (uint256 i = 0; i < _owners.length; i++) {
address owner = _owners[i];
// 多签人不能为0地址,本合约地址,不能重复
require(owner != address(0) && owner != address(this) && !isOwner[owner], "owner can not be repeated");
owners.push(owner);
isOwner[owner] = true;
}
ownerCount = _owners.length;
threshold = _threshold;
}
此处是简化多签钱包的逻辑,不做多签持有人的变更,持有人的列表和threshold都是固定不变的。
然后写一个打包交易hash的方法,在参数中增加了一个chainid,这是为了防止拿到签名之后可以去其他链执行,进行交易重放。
/// @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;
}
然后写一个验签方法:
/**
* @dev 检查签名和交易数据是否对应。如果是无效签名,交易会revert
* @param dataHash 交易数据哈希
* @param signatures 几个多签签名打包在一起
*/
function checkSignatures(
bytes32 dataHash,
bytes memory signatures
) public view {
// 读取多签执行门槛
uint256 _threshold = threshold;
require(_threshold > 0, "threshold not set");
// 检查签名长度足够长
require(signatures.length >= _threshold * 65, "signature not satisify threshold");
// 通过一个循环,检查收集的签名是否有效
// 大概思路:
// 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], "singer not owner");
lastOwner = currentOwner;
}
}
几个要注意的地方:
- threshold是否满足的判断是由signatures的长度来判断的,因为单个签名的长度固定是65字节。
- ecrecover的时候拼接了
\x19Ethereum Signed Message:\n32,这是因为在使用eth_sign或者钱包签名的时候,会自动在前面加上这一段话,用于标识是签名而非真实的交易数据,所以验签的时候也需要加上。 - lastOwner的记录是因为
signatures的拼接是根据address从小到大进行拼的,这样保证了多签拼接顺序的固定,所以验签时还需要判断address之间的大小关系。
最后实现一个执行合约的方法:
/// @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);
if (success) emit ExecutionSuccess(txHash);
else emit ExecutionFailure(txHash);
}

浙公网安备 33010602011771号