什么是多签钱包
多签钱包是一种更安全的钱包。它的资金交互必须要有多个人联合进行签名才能发起。其核心思想是:只有当预定数量的授权方(签名者)签署了某个事务后,才能执行该操作。通常用于增强钱包的安全性和减少单点故障风险。
这样说可能有点抽象,我举个例子:传统的个人钱包,假如你的私钥泄漏了,那你的资金就会被盗走。而多签钱包,例如有 3
个多签人,设置了门限值为 2
人,即只有三个人中的 2
个人同时签名了这笔交易,资金才能转走。哪怕这三人中的任意一个人私钥泄漏了,资金仍然是安全的。
多签钱包的特点
- 多个授权方 一个多签钱包由多个用户共同管理,每个人都有权限,但权限又不至于过大。
- 设定门限值
通常会设置一个门限值,例如
3/5
、2/3
等,即至少需要多少个签名才能执行一个敏感操作。 - 增加安全性 比单一签名钱包要更加安全,因为一个私钥泄漏,攻击者仍然无法单独发起交易。
- 防止滥用 由于每个操作需要多个签名,防止了单个用户滥用权限。
Gnosis Safe 多签钱包
Gnosis Safe
是以太坊流行的多签钱包,通过智能合约的方式,管理了超过 10
亿的资产。是一种去中心化的、易于使用、安全的多签钱包。
多签钱包的实现流程
-
实现的核心 在以太坊智能合约中,实现一个智能合约的最核心数据结构是一个保存了所有多签人地址的数组
address[] owners
和一个门限值uint8 threshold
。```js // 多签的门限值 uint8 public constant threshold = 2;
// 多签人数组 address[MAX_SIGNEE] public owners;
`` - **链下签名**: 通过搜集超过门限值的多签人对同一笔交易的签名进行聚合,发到智能合约上。 - **链上验签、执行交易**: 在智能合约中,验证签名的有效性,即通过将签名和交易
hash恢复出来签名人的地址数组,然后和智能合约中保存的
owners数组中的多签人进行比较。若恢复出来的地址都在
owners` 数组中且数量等于或者大于门限值,则说明签名有效,执行交易。 -
实现步骤 下面,我将分链下和链上逐一讲解实现的逻辑。(注意,代码仅最小实现,未设置
chainId
、nonce
值来保证重入攻击,也并不实现多签人的动态增删) -
链下签名
- 数据包:
假设我们要发送一个数据包,包括
to
地址、发送的资金value
、调用的函数setValue(uint256)
、函数的值为123
js // 签名的交易数据 address to = address(0x123); uint256 value = 1 ether; bytes data = abi.encodeWithSignature("setValue(uint256)", 123);
2. 构建消息messageHash
在这一步中,我们遵循的是以太坊消息hash
的构造流程,分别对原数据包进行一次keccak256
的hash
,然后再针对这个hash
加上以太坊的标志字符串来二次keccak256
hash
。最终得到一个32
字节的消息哈希。js /*使用消息原始数据恢复出来消息 hash */ function buildMessageHash( address to, uint256 value, bytes memory data ) internal view returns (bytes32){ bytes32 dataHash = keccak256(abi.encode(to, value, keccak256(data))); bytes32 messageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)); return messageHash; }
3. 分别签名,聚合签名 在这一步中,链下的多签人每个人分别用自己的私钥,对上一步得到的messageHash
进行单独的签名。当达到门限值的多签人进行了这个签名流程后(这里用门限值设置为2
,将用2
个人演示),由某个人将这2
个人的签名收集起来,简单拼接聚合成为一个大签名(一个签名大小为65
字节,2
个人拼接起来则为130
字节)```js /生成并聚合签名/ function generateSignatures(bytes32 messageHash) public returns (bytes memory) { uint8 v; bytes32 r; bytes32 s; // 模拟签名 (v,r, s) = vm.sign(privatekeys[1], messageHash); bytes memory sig1 = abi.encodePacked(r, s, v); // 模拟签名 (v,r, s) = vm.sign(privatekeys[2], messageHash); bytes memory sig2 = abi.encodePacked(r, s, v);
return abi.encodePacked(sig1, sig2);
} ``` 4. 将原始数据包、聚合签名发送出去给多签合约 有了原始数据包、聚合签名后,我们就可以将这个交易发送到多签合约中。
```js /执行交易/ function testExecuteTransaction() public { /构建 32 字节消息 hash/ bytes32 messageHash = buildMessageHash(to, value, data);
// 模拟链下签名,并聚合成一个签名 bytes memory signatures = generateSignatures(messageHash); // 执行交易 multiSig.executeTransaction(to, value, data, signatures);
}
`` - **链上验签、发送交易** 在链上多签合约中,我们是基于最核心
address[3] owners` 这个多签人数组进行操作的。```js // 多签的门限值 uint8 public constant threshold = 2;
// 多签人数组 address[MAX_SIGNEE] public owners;
`` 其中,多签钱包执行交易的核心函数为
executeTransaction这个函数。它包含了恢复消息、检查签名、执行调用三步,下面且看我逐一讲解。 1. **恢复消息** 这一步和链下的构建
messageHash是一致的,都是使用到了原数据包经过两次
hash后获取一个
32字节的消息
hash` 的过程。```js /使用消息原始数据恢复出来消息 hash / function recoverMessageHash( address to, uint256 value, bytes memory data ) internal view returns (bytes32){
bytes32 dataHash = keccak256(abi.encode(to, value, keccak256(data))); bytes32 messageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)); return messageHash;
}
2. **检查签名** 在这一步中,我们需要做的是,先检查签名的长度是否是 `130` 字节(单个签名长度为为 `65` 字节,门限值为 `2`,故为 `130` 字节),然后对聚合签名进行分隔成两份,每一份都使用 `ECDSA.recover()` 来恢复出来地址 `recovered` 。然后,我们将这个恢复出来的地址和我们合约中保存的多签人地址 `owners` 进行比较。判定条件有两个:只有 恢复出来的 `recovered` 存在这个数组中,且超过门限值(`2` 人)都在这个数组中,则认为签名有效。
js /检查签名是否正确/ function checkSignatures(bytes32 messageHash, bytes memory signatures) internal view { /检查签名长度是否正确/ require(signatures.length >= threshold * 65, "signature length is wrong"); uint pos = 0; for (uint i = 0; i < threshold; i++) { address recovered = ECDSA.recover(messageHash, signatures.slice(pos, 65)); pos = pos + 65; require(checkInOwner(recovered), "verify fail! signatures are wrong!"); } }/检查是否在多签人数组中/ function checkInOwner(address recovered) view internal returns (bool){ for (uint i = 0; i < MAX_SIGNEE; i++) { if (owners[i] == recovered) { return true; } } return false; }
`` 2. **发出交易** 在这一步中,所需要做的就比较简单了,前面做的验证都通过了,这一步只需要使用
call()调用
to` 地址的函数,并将多签钱包中的资金转出去即可。js /*执行多签钱包调用、转出资金*/ (bool success,) = to.call{value: value}(data);
手搓一个极简版的 Gnosis Safe 多签合约
下面是完整代码实现,不想看的可以直接跳转到我的代码仓库 仓库链接 - 链下测试
multiSignatureTest.t.sol
- 数据包:
假设我们要发送一个数据包,包括
contract MultiSignatureTest is Test {
uint8 constant MAX_SIGNEE = 3;
MultiSignature multiSig;
address[MAX_SIGNEE] owners;
uint256[MAX_SIGNEE] privatekeys;
// 签名的交易数据
address to = address(0x123);
uint256 value = 1 ether;
bytes data = abi.encodeWithSignature("setValue(uint256)", 123);
function setUp() public {
for(uint i=0;i<MAX_SIGNEE;i++){
// 用时间戳生成随机私钥
uint256 privateKey = uint256(keccak256(abi.encodePacked(block.timestamp, i)));
// 使用 Foundry 的 vm.addr() 来根据私钥生成地址
address addr = vm.addr(privateKey);
owners[i] = addr;
privatekeys[i] = privateKey;
}
multiSig = new MultiSignature(owners);
// 为合约地址分配一定的 ETH,方便测试多签合约转出 (例如:100 ETH)
vm.deal(address(multiSig), 100 ether);
}
/*执行交易*/
function testExecuteTransaction() public {
/*构建 32 字节消息 hash*/
bytes32 messageHash = buildMessageHash(to, value, data);
// 模拟链下签名,并聚合成一个签名
bytes memory signatures = generateSignatures(messageHash);
// 执行交易
multiSig.executeTransaction(to, value, data, signatures);
}
/*生成并聚合签名*/
function generateSignatures(bytes32 messageHash) public returns (bytes memory) {
uint8 v;
bytes32 r;
bytes32 s;
// 模拟签名
(v,r, s) = vm.sign(privatekeys[1], messageHash);
bytes memory sig1 = abi.encodePacked(r, s, v);
// 模拟签名
(v,r, s) = vm.sign(privatekeys[2], messageHash);
bytes memory sig2 = abi.encodePacked(r, s, v);
return abi.encodePacked(sig1, sig2);
}
/*使用消息原始数据恢复出来消息 hash */
function buildMessageHash(
address to,
uint256 value,
bytes memory data
) internal view returns (bytes32){
bytes32 dataHash = keccak256(abi.encode(to, value, keccak256(data)));
bytes32 messageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash));
return messageHash;
}
}
- 链上多签钱包
MultiSignature.sol
contract MultiSignature {
uint8 public constant MAX_SIGNEE = 3;
// 多签的门限值
uint8 public constant threshold = 2;
// 多签人数组
address[MAX_SIGNEE] public owners;
event ExecutionSuccess(bytes32 txHash); // 交易成功事件
event ExecutionFailure(bytes32 txHash); // 交易失败事件
// 初始化签名地址
constructor(address[MAX_SIGNEE] memory _owners){
for (uint256 i = 0; i < MAX_SIGNEE; i++) {
address owner = _owners[i];
require(owner != address(0) && owner != address(this), "signee address wrong!");
owners[i] = owner;
}
}
// 验签后执行交易(利用call进行转发)
function executeTransaction(address to, uint256 value, bytes memory data, bytes memory signatures) external {
/*恢复消息 hash*/
bytes32 messageHash = recoverMessageHash(to, value, data);
/*检查签名*/
checkSignatures(messageHash, signatures);
/*执行多签钱包调用、转出资金*/
(bool success,) = to.call{value: value}(data);
if (success) {
emit ExecutionSuccess(messageHash);
} else {
emit ExecutionFailure(messageHash);
revert("Transaction failed");
}
}
/*检查签名是否正确*/
function checkSignatures(bytes32 messageHash, bytes memory signatures) internal view {
/*检查签名长度是否正确*/
require(signatures.length >= threshold * 65, "signature length is wrong");
uint pos = 0;
for (uint i = 0; i < threshold; i++) {
address recovered = ECDSA.recover(messageHash, signatures.slice(pos, 65));
pos = pos + 65;
require(checkInOwner(recovered), "verify fail! signatures are wrong!");
}
}
/*检查是否在多签人数组中*/
function checkInOwner(address recovered) view internal returns (bool){
for (uint i = 0; i < MAX_SIGNEE; i++) {
if (owners[i] == recovered) {
return true;
}
}
return false;
}
/*使用消息原始数据恢复出来消息 hash */
function recoverMessageHash(
address to,
uint256 value,
bytes memory data
) internal view returns (bytes32){
bytes32 dataHash = keccak256(abi.encode(to, value, keccak256(data)));
bytes32 messageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash));
return messageHash;
}
}
- 脚本测试
forge test --match-path test/multiSignature/multiSignatureTest.t.sol -vvvv