9월 15, 2021

Donkey Contract 간단 탐방기

TL;DR : 코드 공개 이슈가 있었던 Donkey의 바이트코드를 간단한 Decompile 기법을 이용해서 확인해 보았다. Donkey는 랜딩 프로토콜인 Compound의 Fork인 것으로 보이며, 현재 소스 코드를 Verify하지 못하는 이유는 Audit 결과에 따른 마이너 업데이트가 아직 진행 중이기 때문인 것으로 보인다.

Donkey Contract 간단 탐방기

얼마전 아톰릭스랩 정우현 대표님이 체인파트너스에서 출시한 탈중앙화 대출 서비스인 Donkey에 대해서 스마트 컨트랙트 소스를 공개하라는 글을 쓰셨다.

정우현--표철민 충돌 ”디파이 소스 공개하라” vs ”추후 공개” ..클레이스왑으로도 불똥 | 블록미디어
체인파트너스(체파)가 런칭한 디파이 돈키에 대해 ”디파이 스마트콘트렉트 소스를 공개하라”는 주장이 나왔다. 체파의 표철민 대표는 ”더 많은 감사를 받은 후 소스를 공개하고, 스마트콘트렉트도 오픈하겠다”는 입장을 내놨다. 오픈소스 공개를 주장한 아톰릭스의 정우현 대표는 ”투자자

DeFi는 코드에 기반하여 금융 거래를 진행하는 서비스이니 통상적으로 코드를 공개하는 것이 맞다. 더군다나 Donkey는 수호아이오의 보안감사까지 받았으니 사실 코드에 크게 이상이 없을 것이다. 그냥 공개해도 크게 문제 없을 터인데, 체인파트너스 표철민 대표님께서는 다른 업체에도 보안감사 끝내고 Verify하겠다고 말씀을 하신다.

자꾸 안보여주시니 왜 안보여주시는지 궁금하기도 하고 해서 궁금증을 참지 못하고 컨트랙트를 간단하게 까서 어떻게 되어있나 보기로 했다. 아마 모든 Staking Pool들이 구조는 비슷비슷할터이니, 예치된 자산이 가장 많아보이는 dETH Pool을 대상으로 보기로 하자. dETH Pool의 위치는 아래와 같다.

Etherscan: dETH Contract

위의 위치로 가보면 Contract 탭에 초록색 체크 표시와 함께 Contract Source Verified라고 되어있다. 그렇다면 소스코드가 인증이 되어 있는 것인가? 굳이 힘겹게 코드를 알아보려는 시도를 하지 않아도 되는가?

그렇지 않다. Contract의 이름을 보면 TransparentUpgradableProxy 라고 되어 있다. DApp의 사용자가 Proxy Contract와 상호작용한다는 것은, Proxy Contract에는 Token의 잔고액과 같은 상태값만 저장되어 있고, 토큰을 전송하거나 Stake 관련 계산을 하는 구현체 Contract는 어딘가 다른 곳에 존재한다는 의미이다.

출처 : OpenZepplin Blog

자, 그럼 구현체를 찾아 여행을 떠나보자. 어떻게 하면 구현체를 찾아 볼 수 있을까? 이때 활용하는 것이 우리가 Ethereum 상에 존재했지만 거의 신경쓰지 않았던 탭인 Event 탭이다. OpenZepplin에서 만든 TransparentUpgradableProxy 컨트랙트는 구현체를 변경할 때마다 Upgraded(address)라고 구현체 Contract의 주소를 실어서 Event를 발생시켜준다. 우리는 Upgraded(address) 이벤트가 발생하였는지, 발생하였다면 지금 구현체는 어디에 있는지만 Event 탭을 통해 알아내면 되는 것이다.

Ethereum Signature Database

Ethereum Signature Database에 따르면, Upgraded(address)의 Hex Signature는 다음과 같으니 Etherscan에 해당 Signature를 가진 Event가 있었는지 질의해 보면...

흥미로운 결과가 나온다. 글이 작성된 9월 15일 새벽 기준으로 13일 20시간 전이면 Donkey의 서비스 출시일이니 컨트랙트가 배치되는 것은 당연한 일인데, 그 이후로 3번, 가장 최근에는 8시간 전인 9월 14일 오후 7시 경에 Contract의 구현 내용이 바뀌었다.

사실 DeFi Contract에서 Contract Upgrade는 굉장히 민감한 이슈이다. Meerkat Finance의 사례를 보면, Contract 개발자들이 투자자 몰래 코드를 수정하고 자산을 빼돌리는 "Rug Pull"이 일어날 수 있기 때문에, Contract Upgrade는 통상적으로 Timelock을 걸어 24-48시간 정도의 실행 유예를 두고 커뮤니티가 판단할 수 있게 하는 경우가 많다. Donkey는 국내에서 잘 알려진 분들이 책임을 지고 운영을 하고 있다고 하니 Rug Pull에 대해서는 조금 덜 걱정이 되긴 하지만, 그래도 DeFi라면 Contract Upgrade에 대해서 정확히 커뮤니티에 공시했으면 좋았을 것이다.

아무튼 코드를 보자!

가장 최근에 업데이트 된 구현체 Contract인 0x9883480e59081fE8772bF01196965b0Da0e770d1을 향해서 나아가 보자. Etherscan에서 조회해 보면,

Etherscan: dETH Implementation

코드가 Verify되어 있지 않아서 정확히 어떤 작동을 하는지 알 수 없다. 하지만 어떻게라도 그 구조를 들여다보고 싶기 때문에, 제공되어 있는 바이트코드를 사람이 읽을 수 있는 형태로 재구성해주는 decompiler 도구들을 이용해서 그 단편을 볼 수 있을 듯 하다. 평소에는 EtherVM을 사용하여 decompile을 하는데, 어째서인지 해당 Contract는 디컴파일을 제대로 하지 못한다. Panoramix라는 또다른 도구를 이용하여 구조를 확인할 수 있는데, 이 도구도 완벽하게 decompile을 하지는 않아서 일단 decompile이 되는 0xb82..0dd의 초기 구현체를 간략하게 확인하고, Panoramix로 어떤 업데이트가 이루어졌는지 간단하게 보려고 한다.

dETH Implementation Contract :: Public Method

Decompile을 싹 돌려보니 코드 시그니쳐와 함수명이 굉장히 익숙하다. redeemUnerlying...repayBorrowBehalf...seize. Donkey와 같은 대출 프로토콜의 일종인 Compound의 함수명들과 일치한다. 그러면 Compound와 코드가 같을지 Decompile된 코드 중에 일부와 Compound의 CEther 코드를 비교해 보자.

function getCash() returns (var r0) {
	var var0 = 0x00;
	var var1 = var0;
	var var2 = var1;
	var var3 = 0x307d;
	var var4 = address(this).balance;
	var var5 = msg.value;
	var3, var4 = func_33C0(var4, var5);
	var temp0 = var3;
	var1 = temp0;
	var2 = var4;
	var3 = 0x00;
	var4 = var1;
    
	if (var4 > 0x03) { assert(); }
    
	if (var4 == var3) { return var2; }
	else { revert(memory[0x00:0x00]); }
}
Decompile된 getCash()
function getCash() external view returns (uint) {
	return getCashPrior();
}
 
function getCashPrior() internal view returns (uint) { 
	(MathError err, uint startingBalance) = subUInt(address(this).balance, msg.value); 
	require(err == MathError.NO_ERROR); 
	return startingBalance; 
} 
CEther.sol의 getCashPrior()

위의 두 코드를 비교하면 당장에는 크게 비슷해 보이지 않을 것이다. 하지만 찬찬히 작동을 비교해 보면, 사실 두 코드가 완전히 같은 기능을 하는 코드임을 알 수 있다. CEther.sol의 getCash는 사실상 getCashPrior와 같은 함수이다. getCashPrior는 Contract가 가진 잔고액에서 현재 Transaction에서 받은 값을 빼고, 빼기 연산 과정에서 에러가 없을 경우, 뺄셈의 결과인 startingBalance를 내어놓는 함수이다. 위의 Decompile된 코드도 이 Contract의 잔고액(var4)와 현재 Transaction에서 받은 값을 (var5) 빼고 (func_33C0) 에러가 없으면, 연산결과를 내어놓는 함수이다.

그럼 getCashPrior는 어디갔냐고 하면, 아마 Compiler가 바이트코드로 바꾸는 과정 중에, 굳이 필요하지 않다고 판단해서 최적화되어 사라졌거나, 개발자가 임의로 제거했을 가능성이 있다.

다른 대부분의 코드들도 하나씩 비교해 보면, CEther.sol, CToken.sol의 구현들을 따라가고 있는 것을 확인할 수 있다. 이를 더욱 확실히 확인할 수 있는 곳은 Donkey 보안감사 보고서인데, 보안감사 보고서에 나와있는 코드의 단편들을 보면 Compound의 코드에서 Comp를 Donkey로 수정한 정도임을 알 수 있다.

Compound 코드 (위) Donkey 코드 (아래)

Cream Finance가 Compound에서 파생된 것 처럼, Donkey 역시 Compound에서 시작하는 것으로 보인다.

간단하고 개략적인 분석을 통해 Donkey가 Compound의 Fork라는 것을 알아냈다. 그럼 이제, 최근에 일어난 Contract 업데이트들은 어떤 것들을 수정한 것인지 알아보자.

Panoramix를 이용해 초기의 구현 코드와 업데이트 된 코드를 각각 Decompile하고, 그 결과를 비교해 보았다.

감사 보고서에서 제안하는 대로, Event를 추가하는 것처럼 파악하기 쉬운 것들도 있었지만, Panoramix의 도구의 정확성 자체가 좀 떨어지는 편이라, 정확히 어떤 부분이 어떻게 수정 되었다고 알아보기는 어려웠다. 함수의 갯수가 줄어들고, call의 갯수가 줄어든 것으로 추측하건데, Optimization과 Audit에 따른 몇몇 마이너 패치가 적용된 것이 아닌가 한다.

정리하며

지금까지 코드 공개 이슈가 있었던 Donkey의 코드를 간단한 Decompile 기법을 이용해서 확인해 보았다. Donkey는 랜딩 프로토콜인 Compound의 Fork인 것으로 보이며, 현재 소스 코드를 Verify하지 못하는 이유는 Audit 결과에 따른 마이너 업데이트가 아직 진행 중이기 때문인 것으로 보인다. 한국에는 DeFi 생태계가 크게 자라나지 못하고 아는 사람들끼리만 하는 것이 많이 안타까웠는데, Donkey가 불편한 UI/UX 적인 문제로 접근을 해서 어느정도 사람들을 모으는 모습이 신기했다. 분석하면서 이래저래 아쉬운 부분도 보았지만, 건강한 비판과 성장을 통해 대한민국에 더욱 건실한 DeFi 생태계가 자라나길 기대해 본다.

다음에 시간이 난다면 Compound Fork의 운용의 핵심인 ComptrollerPriceOracle이 어떻게 운영되고 있는지 한번 볼까 싶다.

* 본 포스팅의 내용은 어떠한 투자상품의 안정성도 보장하지 않으며, 이 글을 참조하여 이루어진 투자와 그로 인해 발생하는 그 어떠한 피해에 대해서도 책임지지 않습니다.