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號