Edit

Multisig Wallet #2

220.Blockchain 이더리움 Ethereum 티스토리 multisigwallet ethereum

볼만한 Multisig Wallet 코드는 Consensys Gnosis 가 있다.

Consensys 의 Multisig Wallet 은 아직도 상당히 많은 양의 ETH 를 Holding 하며 잘 쓰이고 있고, 핵심인 Contract Code 만 올라가 있는 상태이다. Contract Code 는 업데이트 되지는 않고 있다.

Gnosis 의 코드같은 경우, Angular.js 기반의 Front-End 까지 Push 되어 있으며, Contract 또한 잘 구조화 하였고 Truffle 로 Test 와 Migration 이 가능하도록 Truffle Project 로 만들어 놓았다. 아직도 간간히 dApp 부분은 업데이트 되고 있다.

이번 글에서는 Consensys 의 MultisigWallet 코드를 봐보도록 하겠다. Gnosis 의 것이 잘 되어있기는 하지만, dApp 의 UI 쪽 코드가 대부분이며 핵심을 보기에는 오히려 그 외 적인 코드들이 많다.

Test 환경

이제는 dApp 개발하시는 분들이면 다들 쓰고 있을 TruffleGanache-CLI 를 사용하도록 한다.
Truffle 의 develop 커맨드를 사용해도 되겠지만 옵션 지정에 한계가 있는 관계로 보통은 Ganache-CLI 를 사용한다.

IDE 는 IntelliJ 에 Solidity 플러그인Solhint 를 사용하고 있지만, Solhint 의 Lint 기능이 Remix-IDE 보다 떨어지고 특히 내가 사용중인 IntelliJ 버전의 solhint 플러그인은 .solhint.json 이 제대로 안먹는 관계로 편집은 Remix 로 한다. 필요한 경우 Terminal 에서 solhint 를 직접 쳐서 Linting 을 해주는 상황이다.

Test 는 UI 가 필요한 상황이 아니라면, 대부분 Truffle Test 를 사용하도록 한다. Mocha + Chai 를 사용하여 테스트 코드를 만드는게 좀더 체계적이고 많은 테스트를 자동화 해줄 수 있기 때문에 사용한다.

요즘은 개발환경 구성과 관련한 글들은 여기저기서 많이 찾을 수 있으니 설치나 구성 방법은 생략 한다.

주요 Contract 코드

Consensys 의 MultisigWallet 은 딱 하나의 Contract 인 MultisigWalletWithDailyLimit.sol 이 들어있다. 이 Contract 를 이해 하면 시간내어 Gnosis 의 MultisigWallet 같은 녀석도 만들어갈 수 있다

Contract 는 간단히 2개로 구성된다. modifier 몇개와 이름만 봐도 어떤기능을 할지 짐작이 가는 녀석들이 있다. 중요 modifier 는 onlyWallet, confirmed, validRequirement 정도가 볼만 하며, Function 으로는 addOwner, submitTransaction, confirmTransaction, executeTransaction 정도라고 할 수 있겠다

아래 Contract 는 solc 0.4.10 에서 Compile 된다. pragma definition 만 바꾸면 0.4.13 까지는 큰 오류 없이 compile 된다. constant, throw, constructor 등등.. 그간의 변화가 반영되지 않은 코드이지만 원리 이해에는 별 무리가 없어서 그냥 사용한다

solc 는 지난달 공식 Release 된 0.5.0 에 와서 많은 변화들이 생겼다. 나중에 직접 코딩하는 경우에는 0.5 버전대를 기준으로 올리도록 하겠다. (하아.. 수없이 바뀌어가는 Solidity 도 이제 힘들다. Vyper 로 넘어가야 하나..ㅋ)

주요 Modifier

onlyWallet

modifier onlyWallet() {
if (msg.sender != address(this))
throw;
_;
}

위 Modifier 는 msg.sender 가 현재 Contract 가 아니면 throw 로 튕기라는 내용이다. 이게 왜 필요할까 싶을 수 있다.

onlyWallet modifier 를 사용하는 Function 은 addOwner, removeOwner, replaceOwner, changeRequirement,changeDailiyLimit 이다. 이 Function 들은 Wallet 고유의 기능(설정)을 변경하는 기능을 수행한다는 것이다. 즉 Wallet 이 다른 Address 로 Transaction 을 Submit 하지 않으며 Wallet 내부에서 모든 기능이 끝난다.

이러한 함수는 EOA 에서 Wallet Contract 로 Message Transaction 을 Submit 할 때, Data 파트에 to 에 해당하는 parameter(여기서는 submitTransaction 의 destination) 를 본 MultiSigWallet 의 Contract 로 하고, 함수의 Signature 를 위 5개 중의 하나의 함수로 하며 함수의 파라미터에 맞는 값을 넣어 submitTransaction 혹은 executeTransaction 으로 Submit 해주어야 한다.
(아래 나올 submitTransaction, executeTransaction 을 보면 이해가 갈 것이다)

이때, 위 5개 함수는 MultiSigWallet 이 .call() 로 자신이 Sender 가 되어 호출하게 된다. 그래서 이런 류의 호출만을 Accept 하기 위해 onlyWallet() modifier 를 만들어서 쓴다.

사실 이번 Blog 를 쓰는 주된 이유가 .call() / .delegatecall() / staticcall() 과 관련된 설명을 이후에 하기 위함이니 이어질 설명과 테스트를 통해 잘 이해 해두도록 하자

추가로, throw 는 deprecate 된지 오래이며, revert() 와 동일하게 취급된다. 즉, gas 는 throw 이전 까지 실행된 만큼만 소모된다

confirmed

mapping (uint => mapping (address => bool)) public confirmations;
modifier confirmed(uint transactionId, address owner) {
if (!confirmations[transactionId][owner])
throw;
_;
}

confirmed modifier 는 함수 중 revokeConfirmation() 에서만 사용된다. confirmations mapping 을 보면, uint => address => bool 로 되어있는것을 볼 수 있으며, transactionId => owner => true/false 형태로, submit 된 transactionId 에 revokeConfirmation() 을 실행한 ownerconfirm을 했는지 가 기록되어있는지를 확인해주고, 아니라면 throw 로 튕겨내는 기능을 한다.

validRequirement

uint public required;
modifier validRequirement(uint ownerCount, uint _required) {
if ( ownerCount > MAX_OWNER_COUNT
|| _required > ownerCount
|| _required == 0
|| ownerCount == 0)
throw;
_;
}

required 는 MultiSigWallet 의 constructor 에서 지정하도록 되어있으며, 전체 owner 의 수보다는 작아야 하며 owner 가 0 보다 커야 valid 하다는 조건을 가지도록 modifier 로 filtering 한다.

required 는 changeRequirement() 를 통해 변경될 수 있으며, changeRequirement() 함수는 onlyWallet modifier 에 의해 실행될 수 있다. 그 얘기는, changeRequirement() 를 실행시키기 위한 Transaction 을 Wallet 으로 submit 해야 하며, 이에 대해 이전에 설정된 required 만큼의 owner 가 confirm 을 해야 이 또한 실행될 수 있음을 의미한다

주요 Functions

addOwner

function addOwner(address owner)
public
onlyWallet
ownerDoesNotExist(owner)
notNull(owner)
validRequirement(owners.length + 1, required)
{
isOwner[owner] = true;
owners.push(owner);
OwnerAddition(owner);
}

confirm 할 자격이 있는 owner 를 추가하는 함수이다. 이 함수 또한 onlyWallet modifier 가 있어, 이 함수를 실행하기 위한 Transaction 이 Submit 되어 있어야 하고, Wallet 이 내부적으로 이 함수를 .call() 로 호출 하여야 한다. 결국, required 만큼의 confirm 이 있어야 owner 가 추가될 수 있다.

submitTransaction

function submitTransaction(address destination, uint value, bytes data)
public
returns (uint transactionId)
{
transactionId = addTransaction(destination, value, data);
confirmTransaction(transactionId);
}

실행시키고자 하는 Transaction 을 submit 하기 위해 호출하는 함수이다. Transaction 의 to 주소를 destination 으로, 이체 되어야 하는 ETH 의 양을 value 에 넣어준다. 그리고 to 주소가 Contract 라면 호출할 Contract 의 Call Code Data 를 data parameter 에 넣어준다. (이부분은 테스트 코드에서 설명 예정이다)

addTransaction() 은 internal 함수로, transactions mapping (transactionId => Transaction)에 Transaction 구조체의 Instance 를 만들어, 단순한 transaction count 를 ID 로 하는 transactionId 를 key 로 하여 저장 해준다.

마지막으로, confirmTransaction() 함수를 통해 submitTransaction 을 호출한 owner 의 confirm 을 자동 실행하게 한다.

confirmTransaction

function confirmTransaction(uint transactionId)
public
ownerExists(msg.sender)
transactionExists(transactionId)
notConfirmed(transactionId, msg.sender)
{
confirmations[transactionId][msg.sender] = true;
Confirmation(msg.sender, transactionId);
executeTransaction(transactionId);
}

confirmTransaction 은 등록된 특정 transactionId 에 대해 owner 가 confirm 을 하기 위한 함수이다. 내용은 위에서 설명한 confirmations (transactionId => owner => true/false 관계를 갖는 mapping) 에 msg.sender (confirmTransaction 을 실행 한 sender) 가 owner 인 경우, 해당 owner 가 confirm 했다고 true 로 바꾸어 주고, Event 를 emit 하는것이다.

마지막으로, 다음에 설명할 executeTransaction() 을 통해, confirm 의 수가 required 보다 같거나 많으면 실제 Transaction 을 실행하게 한다.

executeTransaction

사실 이번 글을 쓰는 주된 목적중의 하나가 아래 코드, 그중 .call() 함수 이다.
call delegatecall staticcall 을 사용하면 Contract 가 ABI 를 모르는 다른 Contract 의 Method 를 호출하도록 하는 Internal Transaction 을 만들어 실행시킬 수 있다. 예전에는 callcode 도 있었으나 0.4.25 버전을 끝으로 사라졌고 staticcall 이 새로 생겨났다.

function executeTransaction(uint transactionId)
public
notExecuted(transactionId)
{
if (isConfirmed(transactionId)) {
Transaction tx = transactions[transactionId];
tx.executed = true;
if (tx.destination.call.value(tx.value)(tx.data))
Execution(transactionId);
else {
ExecutionFailure(transactionId);
tx.executed = false;
}
}
}

isConfirmed() 로 transactionId 에 해당하는 transaction 이 required 이상의 confirm 을 받은 경우, transactions 안에 저장해둔 Transaction 을 실행한다.

실제 transaction 을 실행하는 코드는 아래 부분이다

if (tx.destination.call.value(tx.value)(tx.data))

tx.destination 에는 submitTransaction() 을 호출할 때 넘겨준 destination 즉, transaction 의 to Address 가 들어있다. 이 to Address 에 .call() Low-Level 함수를 호출 해주면 argument 로 넘겨진 Code 를 Internal Transaction 으로 하여 대상 Smart Contract Account 의 Code 를 호출할 수 있게 된다.

주로 호출되는 함수들은 아래와 같은 Sequence 로 동작한다고 보면 된다.

  1. Sender 1 이 MultisigWallet Contract 의 submitTransacton() 을 호출하게 되는데, 이때 parameter 로 “0xCD01…” 주소의 destination 을 주고, 이체할 Ether (MultiSigWallet Contract 내에 자신이 Deposit 한 금액 중 일부)를 적어줄 수 있으며, 마지막으로, destination account (Smart Contract Account) 에서 실행되어야 하는 data 파트 (Function Hash + parameter 들) 을 넣어주는 것이다

  2. MultisigWallet 의 submitTransaction() 은 내부적으로 addTransaction() 을 통해 parameter 를 통해 받은 최종 Transaction 정보를 저장하고 있는다

  3. msg.sender 의 confirm 을 추가해주고

  4. Transation Receipt 의 Log 를 통해 Confirmation Event 를 Logging 해줄 것이다 (transactionId=1, 사실은 Submission Event 를 통해 Confirmation 전에 transactionId 가 Loggiing 된다)

  5. Sender 2 가 1번 Transaction 에 대해 Confirm 을 하고

  6. 이 또한 Confirmation Event 로 날아간다

  7. required 가 2 이라면, executeTransaction() 이 실행 된다

  8. 저장되어있던 Transaction Destination (“0xCD01…”) 으로 100 wei 만큼을 value 로 transfer 하면서 “0xD01A8D…” 의 data 부분을 “0xCD01…” Contract 에서 실행시키게 된다


다음편에는 Test Script 를 통해 실행 하고 마치는걸로~


반응형
블로그 이미지

Good Joon

IT Professionalist Since 1999

,