[Solidity] Multi Signature Wallet

임형석·2023년 11월 6일
0

Solidity


Multi Signature Wallet

멀티 시그니쳐 월렛은 설정한 2개 이상의 주소가 owner 가 되어 각 주소가 signature 를 생성 후, tx 를 실행하는 방식이다.

예를 들어 2-3 멀티 시그니쳐 월렛의 경우 3개의 주소가 owner 가 되고, 2개 이상의 주소가 tx에 서명한다면 이 tx 를 실행하게 된다.

여러명이 owner 가 될 수도 있고, 한명이 여러개의 주소를 가지고 owner 가 될 수도 있다.

여러명이 owner 인 경우, 민주적인 방법으로 지갑을 제어할 수 있다. 하지만 그만큼 한번의 tx 실행 과정도 복잡하다.

한명이 여러개의 주소를 가지고 owner 가 된 경우, 지갑의 보안성을 높일 수 있다. 만약, 2-3 멀티 시그니쳐 월렛을 보유했을 경우 하나의 주소에 대한 제어를 잃게 되어도 여전히 2개 주소에 대한 제어가 가능하기에 멀티 시그니쳐 월렛의 제어 권한은 본인에게 있다.


코드 작성

글이 너무 길어지기에 코드와 주석으로만 정리한다..

// SPDX-License-Identifier: MIT
pragma solidity 0.8.18;

/*

Multi Signature Wallet
여러 주소가 Multi Signature Wallet 의 공동 owner 가 된다.
owner 들이 설정한 만큼 approve(confirm) 된 tx 는 실행이 가능하다.

- 테스트
TestContract 의 함수 callMe 호출하기

1. 주소 3개를 아래 그대로 복사해서 생성자의 주소로, numberOfConfirmRequired 는 2로 배포.
["0x5B38Da6a701c568545dCfcB03FcB875f56beddC4","0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2","0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db"]

2. TestContract 배포. 원하는 값을 넣고 getData 함수 실행, bytes 값 반환

3. submit 함수 call. (TestContract 주소, value = 0, bytes = 위에 2번에서 얻은 bytes 값)

4. txId 0번을 tx를 지갑을 돌려가며 approve 한다.(1에서 설정한대로 2번 이상 approve)

5. txId 0번을 execution 한다. TestContract 의 i 값이 변경되었는지 확인.

*/

contract MultiSignatureWallet {
    event Deposit(address indexed sender, uint value, uint balance);
    event SubmitTransaction(
        address indexed owner,
        uint indexed txIndex,
        address indexed to,
        uint value,
        bytes data
    );
    event Approve(address indexed owner, uint txId);
    event Revoke(address indexed owner, uint txId);
    event Execute(address indexed owner, uint txId);

    struct Transaction {
        address to;
        uint value;
        bytes data;
        bool executed;
        uint confirmation;
    }

    Transaction[] public transactions;
    uint public numberOfConfirmRequired;
    address[] public owners;
    mapping(address => bool) isOwner;
    mapping(uint => mapping(address => bool)) public isApproved;

    // receive 로 받는 이더를 emit 처리
    receive() external payable {
        emit Deposit(msg.sender, msg.value, address(this).balance);
    }

    // owner 주소를 배열로 처리, 최소 approve 요구 값을 생성자로 설정
    constructor(address[] memory _owners, uint _numberOfConfirmRequired) {
        require(_numberOfConfirmRequired != 0 
        && _numberOfConfirmRequired <= _owners.length,"Invaild confirm number.");
        require(_owners.length > 1,"Minimum two addresses are required.");

        for(uint i; i < _owners.length; i++){
            require(_owners[i] != address(0),"Zero address.");
            require(!isOwner[_owners[i]],"Invaild address");
            owners.push(_owners[i]);
            isOwner[_owners[i]] = true;
        }
        numberOfConfirmRequired = _numberOfConfirmRequired;
    }

    // 지정된 owner 인가?
    modifier onlyOwner() {
        require(isOwner[msg.sender], "Owner only.");
        _;
    }

    // tx 가 실행되지 않았는가?
    modifier checkExecute(uint _txId) {
        require(!transactions[_txId].executed, "Already executed.");
        _;
    }

    // msg.sender 가 특정 tx 를 approve 를 하지 않았는가?
    modifier checkApprove(uint _txId) {
        require(!isApproved[_txId][msg.sender], "Already approved.");
        _;
    }

    // 존재하는 tx 인가?
    modifier txExist(uint _txId) {
        require(_txId <= transactions.length, "Already Executed.");
        _;
    }

    // tx 제출. 다른 지갑의 approve 를 기다리게 됨.
    function submit(address _to, uint _value, bytes memory _data) external onlyOwner {
        require(_to != address(0), "Zero address.");
        require(_value != 0 || _data.length != 0, "Invaild input.");
        transactions.push(Transaction(_to, _value, _data, false, 0));
        emit SubmitTransaction(msg.sender, transactions.length -1, _to, _value, _data);
    }

    // submit 된 tx Approve
    function approve(uint _txId) 
        external 
        onlyOwner 
        checkApprove(_txId) 
        checkExecute(_txId)
        txExist(_txId)
    {
        isApproved[_txId][msg.sender] = true;
        transactions[_txId].confirmation += 1;
        emit Approve(msg.sender, _txId);
    }

    // Approve 했던 tx 취소
    function revoke(uint _txId)
        external 
        onlyOwner
        checkExecute(_txId)
        txExist(_txId)
    {
        require(isApproved[_txId][msg.sender], "Not approved.");
        
        isApproved[_txId][msg.sender] = false;
        transactions[_txId].confirmation -= 1;
        emit Revoke(msg.sender, _txId);
    }

    //   gas   tx   execute ------ 가스비 비교(지역변수X)
    // 86123 74889 53685
    function execution(uint _txId) 
        external
        onlyOwner
        txExist(_txId)
    {
        // 지역변수 사용X, 가스비 소모가 가장 큼
        require(!transactions[_txId].executed, "Already executed.");
        require(numberOfConfirmRequired <= transactions[_txId].confirmation,"Not Approved.");
        
        // re-entry 할 수 없도록 상태변수 변경 후 call
        transactions[_txId].executed = true;
        (bool success, ) = transactions[_txId].to.call
        {value : transactions[_txId].value}(transactions[_txId].data);
        require(success, "Failed to transact.");
        emit Execute(msg.sender, _txId);
    }

    //   gas   tx   execute ------ 가스비 비교(지역변수O storage)
    // 85066 73970 52766
    function execution_2(uint _txId) 
        external
        onlyOwner
        txExist(_txId)
    {
        // storage 사용 시 가스비가 제일 효율적임
        Transaction storage transaction = transactions[_txId];
        require(!transaction.executed, "Already executed.");
        require(numberOfConfirmRequired <= transaction.confirmation,"Not Approved.");

        // re-entry 할 수 없도록 상태변수 변경 후 call
        transaction.executed = true;
        (bool success, ) = transaction.to.call
        {value : transaction.value}(transaction.data);
        require(success, "Failed to transact.");
        emit Execute(msg.sender, _txId);
    }

    //   gas  tx  execute ------ 가스비 비교(지역변수O memory)
    // 85980 74765 53561
    function execution_3(uint _txId) 
        external
        onlyOwner
        txExist(_txId)
    {
        // memory 형태의 지역변수 사용으로는 상태변수 변경이 불가
        Transaction memory transaction = transactions[_txId];
        require(!transaction.executed, "Already executed.");
        require(numberOfConfirmRequired <= transaction.confirmation,"Not Approved.");

        // memory 를 사용하기에 이렇게 변경, 가스비도 많이 소모 됨
        transactions[_txId].executed = true;
        (bool success, ) = transaction.to.call
        {value : transaction.value}(transaction.data);
        require(success, "Failed to transact.");
        emit Execute(msg.sender, _txId);
    }

    // 가장 최근의 txId 를 반환
    function getTxCount() public view returns(uint){
        return transactions.length;
    }
}

// 테스트 컨트랙트
contract TestContract {
    uint public i;

    function callMe(uint j) public {
        i += j;
    }

    // function Selector 역할. 원하는 함수와 인자를 묶어서 bytes 코드로 변환
    function getData(uint _num) public pure returns (bytes memory) {
        return abi.encodeWithSignature("callMe(uint256)", _num);
    }
}

코드 테스트

코드 내에 적어놓은 순서대로 테스트를 한다.

  • TestContract 의 callMe 함수 호출하여 테스트
  1. 주소 3개를 아래 그대로 복사해서 생성자의 주소로, numberOfConfirmRequired 는 2로 배포.
    ["0x5B38Da6a701c568545dCfcB03FcB875f56beddC4","0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2","0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db"]


  1. TestContract 배포. 원하는 값을 넣고 getData 함수 실행, bytes 값 반환


  1. submit 함수 call. (TestContract 주소, value = 0, bytes = 위에 2번에서 얻은 bytes 값)


  1. txId 0번을 tx를 지갑을 돌려가며 approve 한다.(1에서 설정한대로 2번 이상 approve)


  1. txId 0번을 execution 한다. TestContract 의 i 값이 변경되었는지 확인.


0개의 댓글