url: https://solidity-by-example.org/app/multi-sig-wallet/
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract MultiSigWallet {
event Deposit(address indexed sender, uint amount, uint balance);
event SubmitTransaction(
address indexed owner,
uint indexed txIndex,
address indexed to,
uint value,
bytes data
);
event ConfirmTransaction(address indexed owner, uint indexed txIndex);
event RevokeConfirmation(address indexed owner, uint indexed txIndex);
event ExecuteTransaction(address indexed owner, uint indexed txIndex);
address[] public owners;
mapping(address => bool) public isOwner;
uint public numConfirmationsRequired;
struct Transaction {
address to;
uint value;
bytes data;
bool executed;
uint numConfirmations;
}
// mapping from tx index => owner => bool
mapping(uint => mapping(address => bool)) public isConfirmed;
Transaction[] public transactions;
modifier onlyOwner() {
require(isOwner[msg.sender], "not owner");
_;
}
modifier txExists(uint _txIndex) {
require(_txIndex < transactions.length, "tx does not exist");
_;
}
modifier notExecuted(uint _txIndex) {
require(!transactions[_txIndex].executed, "tx already executed");
_;
}
modifier notConfirmed(uint _txIndex) {
require(!isConfirmed[_txIndex][msg.sender], "tx already confirmed");
_;
}
constructor(address[] memory _owners, uint _numConfirmationsRequired) {
require(_owners.length > 0, "owners required");
require(
_numConfirmationsRequired > 0 &&
_numConfirmationsRequired <= _owners.length,
"invalid number of required confirmations"
);
for (uint i = 0; i < _owners.length; i++) {
address owner = _owners[i];
require(owner != address(0), "invalid owner");
require(!isOwner[owner], "owner not unique");
isOwner[owner] = true;
owners.push(owner);
}
numConfirmationsRequired = _numConfirmationsRequired;
}
receive() external payable {
emit Deposit(msg.sender, msg.value, address(this).balance);
}
function submitTransaction(
address _to,
uint _value,
bytes memory _data
) public onlyOwner {
uint txIndex = transactions.length;
transactions.push(
Transaction({
to: _to,
value: _value,
data: _data,
executed: false,
numConfirmations: 0
})
);
emit SubmitTransaction(msg.sender, txIndex, _to, _value, _data);
}
function confirmTransaction(
uint _txIndex
) public onlyOwner txExists(_txIndex) notExecuted(_txIndex) notConfirmed(_txIndex) {
Transaction storage transaction = transactions[_txIndex];
transaction.numConfirmations += 1;
isConfirmed[_txIndex][msg.sender] = true;
emit ConfirmTransaction(msg.sender, _txIndex);
}
function executeTransaction(
uint _txIndex
) public onlyOwner txExists(_txIndex) notExecuted(_txIndex) {
Transaction storage transaction = transactions[_txIndex];
require(
transaction.numConfirmations >= numConfirmationsRequired,
"cannot execute tx"
);
transaction.executed = true;
(bool success, ) = transaction.to.call{value: transaction.value}(
transaction.data
);
require(success, "tx failed");
emit ExecuteTransaction(msg.sender, _txIndex);
}
function revokeConfirmation(
uint _txIndex
) public onlyOwner txExists(_txIndex) notExecuted(_txIndex) {
Transaction storage transaction = transactions[_txIndex];
require(isConfirmed[_txIndex][msg.sender], "tx not confirmed");
transaction.numConfirmations -= 1;
isConfirmed[_txIndex][msg.sender] = false;
emit RevokeConfirmation(msg.sender, _txIndex);
}
function getOwners() public view returns (address[] memory) {
return owners;
}
function getTransactionCount() public view returns (uint) {
return transactions.length;
}
function getTransaction(
uint _txIndex
)
public
view
returns (
address to,
uint value,
bytes memory data,
bool executed,
uint numConfirmations
)
{
Transaction storage transaction = transactions[_txIndex];
return (
transaction.to,
transaction.value,
transaction.data,
transaction.executed,
transaction.numConfirmations
);
}
}
Multi-sig Wallet은 다중 서명 기능을 통해 사용할 수 있는 월렛을 의미한다. 일반적으로 사용자의 월렛은 월렛 소유자인 사용자의 개인키를 통해 사용할 수 있다. 일반적으로, 암호화폐 거래소에서 관리하는 월렛은 안전한 암호화폐의 보호를 위해 단일 사용자의 개인키로만 접근할 수 있는 월렛이 아닌, 다중 서명을 통해 관리하는 Multi-sig Wallet을 사용한다. 다중 서명 월렛은 단일 사용자의 개인키로 서명하는 월렛보다 더 안전하고, 블록체인의 합의처럼 여러 이해관계자에게 권한을 나누어 관리할 수 있는 장점을 가진다.
struct Transaction {
// 트랜잭션을 수신할 address
address to;
// 전송할 eth
uint value;
// 트랜잭션 데이터 (호출할 함수 function signature)
bytes data;
// 해당 트랜잭션 실행 여부
bool executed;
// 트랜잭션을 실행시키기 위해 검증된 address count
uint numConfirmations;
}
// 생성자 함수에서 컨트랙트 실행 인자 (_owners)를 통해 isOwner mapping 변수에 저장
// msg.sender가 owner인지 확인한다.
modifier onlyOwner() {
require(isOwner[msg.sender], "not owner");
_;
}
// txIndex는 transactions 길이보다 작아야 존재한다.
modifier txExists(uint _txIndex) {
require(_txIndex < transactions.length, "tx does not exist");
_;
}
// transaction 객체의 executed (boolean) 값을 통해 실행되지 않았음을 확인
modifier notExecuted(uint _txIndex) {
require(!transactions[_txIndex].executed, "tx already executed");
_;
}
// mapping(uint => mapping(address => bool)) isConfirmed 상태 변수는 txIndex값에 해당하는 address가 Confirm 여부를 저장한다.
modifier notConfirmed(uint _txIndex) {
require(!isConfirmed[_txIndex][msg.sender], "tx already confirmed");
_;
}
constructor(address[] memory _owners, uint _numConfirmationsRequired) {
require(_owners.length > 0, "owners required");
require(
_numConfirmationsRequired > 0 &&
_numConfirmationsRequired <= _owners.length,
"invalid number of required confirmations"
);
for (uint i = 0; i < _owners.length; i++) {
address owner = _owners[i];
require(owner != address(0), "invalid owner");
require(!isOwner[owner], "owner not unique");
isOwner[owner] = true;
owners.push(owner);
}
numConfirmationsRequired = _numConfirmationsRequired;
}
컨트랙트가 최초 배포될 때 해당 multi-sig wallet의 owner를 address 배열 형태로 입력받는다. 해당 owner은 트랜잭션을 생성하거나, 컨펌하고 트랜잭션을 실행시킬 수 있다. 그리고, 생성자 함수에서는 _numConfirmationsRequired 변수를 통해 트랜잭션을 실행시키기 위해 필요한 confirm number를 지정한다.
require 키워드를 통해 입력 값에 대한 정합성 검사, 이후에 입력 인자를 상태 변수에 저장한다.
function submitTransaction(
address _to,
uint _value,
bytes memory _data
) public onlyOwner {
uint txIndex = transactions.length;
transactions.push(
Transaction({
to: _to,
value: _value,
data: _data,
executed: false,
numConfirmations: 0
})
);
emit SubmitTransaction(msg.sender, txIndex, _to, _value, _data);
}
submitTransaction은 (to, value, data) 인자를 통해 트랜잭션을 생성시키고, transacation 배열에 추가한다.
function confirmTransaction(
uint _txIndex
) public onlyOwner txExists(_txIndex) notExecuted(_txIndex) notConfirmed(_txIndex) {
Transaction storage transaction = transactions[_txIndex];
transaction.numConfirmations += 1;
isConfirmed[_txIndex][msg.sender] = true;
emit ConfirmTransaction(msg.sender, _txIndex);
}
confirmTransaction는 상태 변수 transactions[txIndex].numConfirmations 값을 1 증가시키고, isConfirmed 상태 변수에 함수를 실행시킨 msg.sender가 해당 트랜잭션을 검증했다는 true 값을 저장한다. transactions의 상태 변수값을 직접 수정하지 않고 storage 변수를 생성하여 참조 값을 변경했다. 그리고, notConfirmed 함수 변경자를 통해 기존에 confirm 하지 않은 owner만 해당 함수를 호출할 수 있다.
function executeTransaction(
uint _txIndex
) public onlyOwner txExists(_txIndex) notExecuted(_txIndex) {
Transaction storage transaction = transactions[_txIndex];
require(
transaction.numConfirmations >= numConfirmationsRequired,
"cannot execute tx"
);
transaction.executed = true;
(bool success, ) = transaction.to.call{value: transaction.value}(
transaction.data
);
require(success, "tx failed");
emit ExecuteTransaction(msg.sender, _txIndex);
}
executeTransaction 함수는 인자로 받은 _txIndex에 해당하는 트랜잭션에 numConfirmations가 상태 변수 numConfirmationsRequired 이상의 confirm을 받으면 call 함수를 통해 트랜잭션을 실행한다.
function revokeConfirmation(
uint _txIndex
) public onlyOwner txExists(_txIndex) notExecuted(_txIndex) {
Transaction storage transaction = transactions[_txIndex];
require(isConfirmed[_txIndex][msg.sender], "tx not confirmed");
transaction.numConfirmations -= 1;
isConfirmed[_txIndex][msg.sender] = false;
emit RevokeConfirmation(msg.sender, _txIndex);
}
revokeConfirmation 함수는 confirm을 취소하고, 해당 트랜잭션의 numConfirmations 값을 -1 변경한다
다음과 같이 MultiSigWallet 컨트랙트 배포
_owners = ["0x5B38Da6a701c568545dCfcB03FcB875f56beddC4", "0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2"]
_numConfirmationsRequired = 2
Multi-Sig wallet transaction 동작을 위한 테스트 컨트랙트 배포
contract TestContract {
uint public i;
function callMe(uint j) public {
i += j;
}
function getData() public pure returns (bytes memory) {
return abi.encodeWithSignature("callMe(uint256)", 123);
}
}
TestContract.getData()를 통해 외부에서 함수를 호출하기 위한 다음과 같은 function signature 값 얻기
// function Selector (4 bytes) + 0x7b (입력 값 123)
0xe73620c3000000000000000000000000000000000000000000000000000000000000007b
해당 function signature은 callMe(123) 호출을 위한 트랜잭션 input값과 일치하는 것을 확인할 수 있다.
트랜잭션 생성 (submitTransaction)
submitTransaction(
// TestContract address
_to = 0xB34db0d5aA577998c10c80d76F87AfE58b024e5F
// 보낼 eth
_value = 0
// 실행시킬 function signature, TestContract.getData() 결과 값
_data = 0xe73620c3000000000000000000000000000000000000000000000000000000000000007b
)
트랜잭션 Confirm
// owner Address #1: 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2
confirmTransaction(
_txIndex = 0
)
// owner Address #2: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
confirmTransaction(
_txIndex = 0
)
트랜잭션 상태 확인
getTransaction(
_txIndex = 0
)
/*
0: address: to 0xB34db0d5aA577998c10c80d76F87AfE58b024e5F
1: uint256: value 0
2: bytes: data 0xe73620c3000000000000000000000000000000000000000000000000000000000000007b
3: bool: executed false
4: uint256: numConfirmations 2
*/
트랜잭션 실행
executeTransaction(
_txIndex = 0
)
트랜잭션 동작 확인
트랜잭션이 동작해서, testContract 컨트랙트의 i 상태 변수가 호출된 callMe()에 의해 변경된 것을 확인할 수 있다.
참고자료: https://bitcoinmagazine.com/guides/what-is-a-multisignature-wallet