-
Compound를 분석해보자 (2) - 컨트랙트 1편, Borrowing블록체인 2021. 5. 2. 06:44
이번 글에서는 컴파운드에 있는 컨트랙트를 살펴보고 분석해보려고 한다.
컴파운드 컨트랙트는 아래 Github에서 확인해 볼 수 있다.
github.com/compound-finance/compound-protocol먼저 본격적으로 분석해보기 전에, Read me를 보고 어떤 컨트랙트가 있는지 간단하게 정리해보자.
CToken, CErc20, CEther
cToken은 컴파운드의 대출 서비스에 사용되는 가장 핵심적인 토큰이고, cToken 컨트랙트에는 컴파운드의 코어 로직이 정리돼있다고 한다. CErc20과 CEther는 모두 erc-20 표준을 지키는 토큰들인데, 각각 컴파운드 내에서 erc20 토큰들과 이더리움을 wrapping한다고 한다. 그리고 이 토큰들을 가지고 컴파운드에서 발생하는 토큰 발행, 대출, 상환 등의 다양한 일들을 할 수 있다고 한다.
github.com/compound-finance/compound-protocol/blob/master/contracts/CErc20.sol
github.com/compound-finance/compound-protocol/blob/master/contracts/CEther.sol
컨트랙트 구조를 살펴보면 먼저 두 컨트랙트가 모두 CToken을 상속받고 있다.
CToken 컨트랙트는 일단 기본적인 erc20 인터페이스들을 구현하고 그 외의 몇 개의 함수들이 추가로 구현돼있다.
특이한 것은 CToken을 컨트롤하는 컨트롤러가 이름이 Comptroller로 들어가 있는 것이다. 컴파운드 + 컨트롤러 인 것 같은데 여기도 어떤 인터페이스가 있는지 차차 살펴봐야겠다.initialize라는 함수는 처음 CToken이 배포될 때 초기화 작업을 하는 함수인데, admin으로 설정된 주소만이 이 함수를 실행할 수 있다. 많은 부분에서 Mantissa라는 단어가 나오고, 여기서도 매개변수로 initialExchangeRateMantissa라는 변수가 등장하는데, 찾아보니 소수를 실수로 표현하는 방법이라고 한다. 뭐 인코딩 비스무리 한 방법이니 그냥 그런 방법이 있다 정도만 생각해도 될 듯 하다.
좀 더 알아두면 좋을만한게 market이라는 용어인데, 다음 링크가 바로 market이다. (https://compound.finance/markets)
마켓에서는 토큰을 빌리거나(Borrow) 예치할 수 있는데(Supply), 이 때 핵심이 되는 매개변수가 바로 exchangeRate라는 변수인 것 같다. docs를 보면 exchangeRate의 용도가 좀 더 명확하게 보이는데, 어떤 token과 CToken의 교환 비율이라고 생각하면 될 것 같다. 이 exchangeRate는 0.02로 시작해서 점차 전체 마켓 exchangeRate에 수렴한다고 한다.
예를 들어, 1000 DAI를 컴파운드에 공급한다고 가정하고, 이 때 exchange rate가 0.020070이라고 하자. 그러면 유저가 받는 cDAI의 양은 1000 / 0.020070을 계산해서 49825.61 cDAI를 받게된다.어쨋든 컴파운드에 CToken 컨트랙트의 인터페이스를 line by line으로 분석해보려 했으나..1500줄 가까이 되는 코드를 다 보고 글을 쓰는건 말도 안되는 것 같아서 일단 인터페이스들이 어떤 역할을 하는지 보고, 핵심적은 코드들을 살펴보기로 했다.
Mint
CEther나 CErc20에서 mint함수를 호출하면 CToken의 mintInternal -> mintFresh 함수 순서로 호출된다. CToken을 발행한다는 것은 어떤 토큰을 담보로 CToken을 발행한다는 뜻이다. 각 함수를 보며 어떤 로직이 작성돼있는지 살펴보자.
function mintInternal(uint mintAmount) internal nonReetrant returns (uint, uint) { uint error = accureInterest(); if (error != uint(Error.NO_ERROR)) { return (fail(Error(error), FailureInfo.MINT_ACCURE_INTEREST_FAILED), 0); } return mintFresh(msg.sender, mintAmount); }
mintInternal은 CToken에서 처음으로 불리는 internal 함수고, 내용을 보면 간단한 에러 체크를 하는 것 같다. accrueInterest() 함수를 실행하고 여기서 에러가 발생하면 fail, 성공하면 mintFresh함수를 실행한다. accrueInterest함수는 어떤 일을 하는 것일까? 일단 함수 이름을 보면 이자를 쌓는 역할을 하는 것 같다. 주로 CTokenInterface에 선언돼있는 accrualBlockNumber라는 변수를 이용하는데, 주석을 보면 마지막으로 이자를 쌓은 블록 넘버를 기록하는 변수라고 한다. (Block number that interest was last accrued at)
function accrueInterest() public returns (uint) { uint currentBlockNumber = getBlockNumber(); uint accrualBlockNumberPrior = accrualBlockNumber; if (accrualBlockNumberPrior == currentBlockNumber) { return uint(Error.NO_ERROR); } ...
함수의 첫 단계에서는 현재 블록 넘버와 마지막에 이자를 누적한 블록 넘버가 같은지 비교하고, 같다면 0을 리턴한다. 같은 블록에서는 당연히 이자가 0일테니, 직관적으로 이해할 수 있다.
uint cashPrior = getCashPrior(); uint borrowsPrior = totalBorrows; uint reservesPrior = totalReserves; uint borrowIndexPrior = borrowIndex; uint borrowRateMantissa = interestRateModel.getBorrowRate(cashPrior, borrowsPrior, reservesPrior); require(borrowRateMantissa <= borrowRateMaxMantissa, "borrow rate is absurdly high"); (MathError mathErr, uint blockDelta) = subUInt(currentBlockNumber, accrualBlockNumberPrior); require(mathErr == MathError.NO_ERROR, "could not calculate block delta");
그 다음 장에서는 (글을 쓰는 시점에서) 정확하게는 더 봐야겠지만 borrowRate를 계산하는 것으로 보아 컨트랙트의 reserve를 토대로 빌려줄 수 있는 최대 양을 판단하는 코드인 것으로 보인다. 먼저 cash를 가져오는데, getCashPrior 함수의 주석을 보면 "Gets balance of this contract in terms of the underlying" 이라고 나와있다. 즉 컨트랙트에 존재하는 CToken의 잔액을 뜻한다고 생각하면 되겠다.
변수들을 다시 가져와서 지역변수로 저장하는건 가스비를 절약하는 방법인 것 같고, 중요한건 각 변수가 어떤 의미인지 정리하는게 중요하니 하나씩 살펴보면 다음과 같다. (CTokenInterface에 정의돼있는 기준)
- totalBorrows : Total amount of outstanding borrows of the underlying in this market
현재 빌려준 CToken의 양을 뜻한다. - totalReserves : Total amount of reserves of the underlying held in this market
cash랑은 별개로 거버넌스를 통해 전송할 수 있는 비축량을 말한다. - borrowIndex : Accumulator of the total earned interest rate since the opening of the market
마켓 오픈(즉, 컨트랙트가 배포된 후) 이후로 부터 누적된 인덱스 값 (조금 더 자세히 봐야할 것 같다)
그리고 첫 느낌에 가장 핵심적인 부분 중 하나라고 생각이 드는 getBorrowRate 부분.. 아무래도 이번 학기에 Defi risk에 대해서 생각해보기로 했는데, 컴파운드도 대출이라는 금융 영역을 다루는 만큼 빌려줄 수 있는 양의 한도가 있을 것이라는 생각이 들었다. 그래서 getBorrowRate 부분은 좀 더 깊게 뜯어보는게 좋을 것이라 생각했다.
일단 interestRateModel은 인터페이스이고, 정책적인 부분이라 인터페이스로 구현하는 것이 좀 더 유연한 구조인 것 같고, DAI같은 경우는 BaseJumpRateModelV2.sol에 작성돼있다. 로직을 한번 살펴보면...
function getBorrowRateInternal(uint cash, uint borrows, uint reserves) internal view returns (uint) { uint util = utilizationRate(cash, borrows, reserves); if (util <= kink) { return util.mul(multiplierPerBlock).div(1e18).add(baseRatePerBlock); } else { uint normalRate = kink.mul(multiplierPerBlock).div(1e18).add(baseRatePerBlock); uint excessUtil = util.sub(kink); return excessUtil.mul(jumpMultiplierPerBlock).div(1e18).add(normalRate); } } function utilizationRate(uint cash, uint borrows, uint reserves) public pure returns (uint) { if (borrows == 0) { return 0; } return borrows.mul(1e18).div(cash.add(borrows).sub(reserves)); }
return 왜 들여쓰기 안되냐...하...우선 파라미터부터 하나씩 살펴보면 cash = 컨트랙트 내 존재하는 CToken의 양, borrows = 현재 빌려준 CToken의 양, reserves = 총 이자 수익 토큰의 양이다. 그리고 utilizationRate 함수를 통해 util이라는 변수를 계산하는데, (총 대출량 / (컨트랙트 내 CToken 양 + 총 대출량 - 이자 수익)) 수식에 따라 계산한다. 즉 util이라는 변수는 이자 수익을 제외한 모든 토큰(컨트랙트 내 존재하는 토큰 + 대출로 나간 토큰) 중 대출로 나간 토큰 양의 비율이라고 생각하면 되겠다.
그리고 util값이 kink라는 어떤 임계값 이하일때와 아닐때의 로직이 다른데, 아마도 컨트랙트에 CToken이 충분할때와 아닐때의 경우를 나눠놓은 것 같다. 분기를 나눠보면 다음과 같겠다.
util <= kink인 경우는 비교적 대출 비율이 작다는 뜻이며, ((util * multiplierPerBlock) / 1e18) + (baseRatePerBlock) 으로 계산한 값을 borrowRate로 반환한다. 1e18로 나누는 이유는 보통 사용하는 값들이 mastissa라고 하는, 소수점 아래 18자리를 표현하기 위한 방법으로 저장되기 때문인 것 같다. multiplierPerBlock과 baseRatePerBlock과 같은 값들은 JumpRateModel 컨트랙트 생성자에서 주입되는데(외부에서 파라미터로 넣어줘서 컨트랙트를 배포한다는 뜻), 연간 수익률을 넣어주면 블록 타임으로 나눠서 1블록당 어느정도의 이자가 붙는지를 계산해준다. 따라서 컴파운드에서 발생하는 모든 이자는 블록 단위로 붙는다고 생각할 수 있겠다.(당연한건가..)
util > kink인 경우는 대출량이 비교적 많다는 뜻이고, 이 때는 대출 이자율을 늘려서 사람들이 대출을 갚게끔 유도한다고 docs에 나와있었다. 따라서 위의 경우보다는 반환값이 크게 나올것이라는 생각이 들고, 한번 로직을 들여다보자. 먼저 normal rate 값을 계산하는데, 이는 위에서 계산하는 방법과 똑같다. 기본 이자율에다가 (대출량 * 블록당 이자율)을 더해준 값이다. 그런데 여기다가 excessUtil이라는 값을 구하는데, util - kink 값이다. 즉 임계 초과값? 정도라고 생각하면 될 듯. 그래서 최종 블록당 이자율은 normal rate + excessUtil * (jumpMultiplierPerBlock / 1^18) 이 된다. 여기서 쓰이는 jumpMultiplierPerBlock값도 컨트랙트 생성시 외부에서 넣어주는 값이고, 대출량이 많아질 때 이자율을 높이기 위한 파라미터라고 생각하면 되겠다.
뭔가 길어졌는데 결국 getBorrowRate의 핵심 로직은, 상대적으로 대출량이 적을 때(uint <= kink)는 정해진 연간 이자율과 기본 이자율을 토대로 블록 이자율을 계산하고, 대출량이 많을 때(uint > kink)는 이자율을 더 높게 책정하는 것이다.
// CToken.sol uint borrowRateMantissa = interestRateModel.getBorrowRate(cashPrior, borrowsPrior, reservesPrior); require(borrowRateMantissa <= borrowRateMaxMantissa, "borrow rate is absurdly high");
다시 CToken의 accureInterest 함수로 돌아와서, 이렇게 구한 borrowRateMantissa값을 borrowRateMaxMantissa값과도 비교해본다. 정해진 상한을 넘으면 실패하게된다. 왜냐면 이자율이 너무 높다는것은 여러 문제를 내포하기 때문인 것 같다. 일단 대출량이 과도하다는 뜻일 수도 있고, 대출 이자율이 너무 높아져서 시스템에 부실이 생길 위험이 있다는 뜻일 수도 있겠다. (물론 청산 시스템이 잘 작동한다면 괜찮겠지만, 어디까지나 청산 시스템도 사람이 하는 일이라, 이거만 믿고 가다가 낭패를 본 적이 있다고 알 고 있다..)
(MathError mathError, uint blockDelta) = subUInt(currentBlockNumber, accrualBlockNumberPrior); require(mathErr == MathError.NO_ERROR, "could not calculate block delta");
그 다음에는 현재 Block number와 accrual block number의 차이를 통해 block delta값을 구한다. 이 과정에 에러가 나는지 체크하는 로직이 있는데, CarefulMath에 정의된 subUInt 함수를 보면 무조건 앞의 파라미터(currentBlockNumber)가 뒤의 파라미터(accrualBlockNumberPrior)보다 커야만 연산을 수행하고, 아닐 경우 에러를 반환하도록 작성해놨다. unsigned-int기 때문에 이런 조건들을 달아놓은듯 하다.
이제 블록이 만들어짐에 따라 늘어나는 이자를 반영시키기 위해 새로운 지역변수들을 선언한다.
Exp memory simpleInterestFactor; uint interestAccumulated; uint totalBorrowsnew; uint totalReservesNew; uint borrowIndexNew;
simpleInterestFactor는 borrowRate * blockDelta 값이다. accureInterest는 블록 당 이자를 계산하는 로직이지만, 사실 매 블록마다 호출되는 것이 아니다. 텀을 두고 호출될 수 있기 때문에 blockDelta를 계산하는 것이며, 이를 borrowRate에 곱해서 저장해둔다.
그리고 interestAccumulated = simpleInterestFactor * totalBorrows 값으로 계산한다. 좀 풀어서 보면 blockDelta * borrowRate * totalBorrows 이므로, 전체 대출량에다가 경과한 블록만큼의 이자율을 곱한, 즉 이번에 붙을 이자라고 생각하면 되겠다. (총 금액은 포함되지 않은, 순수 이자량)
totalBorrowsNew = interestAccumulated + totalBorrows 이고, 그 전 총 대출량에다가 새로운 이자를 더해서 총 대출량을 새로 산출한다.
totalReservesNew = interestAccumulated * reserveFactor + totalReserves 이고, 정해진 reserveFactor를 통해 새로운 비축량을 구한다.
borrowIndexNew = simpleInterestFactor * borrowIndex + borrowIndex 로 갱신한다. borrowIndexFactor는 지금까지 쌓아온 총 이자율이므로, 수식을 보면 쉽게 이해할 수 있을 것 같다.
실제 코드를 보면 위 값들을 갱신하는 과정에 Math error를 검사하는 로직이 있는데, SafeMath를 통해 구현하고 있다. 코드는 길지만 결국 위의 값들을 새로 구하고, 갱신하고, AccrueInterest 로그를 찍으면 드디어 길고 긴 accrueInterest함수가 끝난다. 다시 정리해보면..
- 기존에 저장된 cash, reserve, borrow, borrowIndex 값들을 들고와서
- borrow 정책에 따라 이번 라운드의 이자율을 계산하고
- 이 과정에서 현재 시장에 풀린 대출량을 보면서 이자율을 다르게 계산한다.
- reserve, borrow, borrowIndex를 갱신한다.
이렇다. 뭔가 오래 이것저것 썼는데 로직이 생각보다 간단하네 -_-;; 힘들당,...
그러나 여기서 끝이 아니다. 이제야 mintInternal의 한 줄 봤을 뿐! accrueInterest() 함수에서 에러가 발생하지 않았다면, 총 대출량이나 새로운 이자율을 계산하는 로직이나 별 이상이 없었다는 뜻이므로 mintFresh함수를 호출한다. 파라미터는 두 가지가 필요한데, 함수 콜하는 사람의 지갑 주소와 발행량이다.
function mintFresh(address minter, uint mintAmount) internal returns (uint, uint) { uint allowed = comptroller.mintAllowed(address(this), minter, mintAmount); if (allowed != 0) { return (failOpaque(Error.COMPTROLLER_REJECTION, FailureInfo.MINT_COMPTROLLER_REJECTION, allowed), 0); } ... ...
먼저 Comptroller에 mint가 가능한지를 물어본다. 컴파운드 레포지토리에 보면 Comptroller가 G6까지 있는데, 자세한건 더 찾아봐야겠지만 버전..인 듯 싶다. 여기에 mintAllowed를 보면...
// ComptrollerG6.sol function mintAllowed(address cToken, address minter, uint mintAmount) external returns (uint) { // Pausing is a very serious situation - we revert to sound the alarms require(!mintGuardianPaused[cToken], "mint is paused"); // Shh - currently unused minter; mintAmount; if (!market[cToken].isListed) { return uint(Error.MARKET_NOT_LISTED); } // Keep the flywheel moving updateCompSupplyIndex(cToken); distributeSupplierComp(cToken, minter, false); return uint(Error.NO_ERROR); }
mintGuadian들이 있어서 여기에 발행이 중단된 상태인지 물어본다. 만약 발행이 중단된 상태라면 정말 심각한 상황이라고 주석에도 명시해두고, 알람도 준다고 하는데, 우리가 목표로 하는 리스크 중 한 가지가 될 수 있으니 요건 좀 킵해둬야겠다. 그 다음 updateCompSupplyIndex는 로직을 살펴보니, 발행 시 업데이트 할 변수들을 갱신해주는 내용이 주를 이루는 것 같다.
function udpdateCompSupplyIndex(address cToken) internal { CompMarketState storage supplyState = compSupplyState[cToken]; uint supplySpeed = compSpeeds[cToken]; uint blockNumber = getBlockNumber(); uint deltaBlocks = sub_(blockNumber, uint(supplyState.block)); if (deltaBlocks > 0 && supplySpeed > 0) { uint supplyTokens = CToken(cToken).totalSupply(); uint compAccrued = mul_(deltaBlocks, supplySpeed); Double memory ratio = supplyTokens > 0 ? fraction(compAccrued, supplyTokens) : Double({mantissa: 0}); Double memory index = add_(Double({mantissa: supplyState.index}), ratio); compSupplyState[cToken] = CompMarketState({ index: safe224(index.mantissa, "new index exceeds 224 bits"), block: safe32(blockNumber, "block number exceeds 32 bits") }); } else if (deltaBlocks > 0) { supplayState.block = safe32(blockNumber, "block number exceeds 32 bits"); } }
supply speed는 아직은 잘 모르겠지만 얼마나 빠르게 공급되는지를 측정하는 지표인 것 같다. 이게 양수일 경우와 아닐 경우에서 로직이 나뉘는데, 양수인 경우는 block Delta, 총 공급량(supplyTokens), supply speed를 이용해서 compSupplyState라고 하는, CToken 공급 관리 변수에 상태를 저장한다. 이거는 더 살펴봐야겠는데, 각 토큰마다 정해진 supplySpeed가 있는 것 같다.
supplySpeed는 ComptrollerStorage에 저장돼있는데, docs를 보면 Unique to each market is an unsigned integer that specifies the amount of COMP that is distributed, per block, th suppliers and borrowers in each market. 이라고 설명하고 있다. 블록 당 suppliers과 borrowers에게 COMP토큰이 분배되는 양을 의미하는 것 같다. 그리고 이 값은 가변적이어서 _setCompSpeed 함수를 통해 변경할 수 있고, 거버넌스를 통해서만 진행할 수 있다고 한다.
따라서 supply speed > 0인 경우는 compAccrued를 통해 컴파운드 토큰 분배량을 계산하고, index를 계산하여 compSupplyState에 저장한다. 그리고 이 값을 토대로 COMP 토큰을 분배받는다고 생각하면 될 것 같다.
supply speed가 0 이하인 경우는 그냥 blockNumber를 supplyState에 저장하기만 한다.그리고 이 작업이 다 끝나면, distributeSupplierComp함수를 호출한다. 이름만 봐도 COMP토큰을 분배하는 함수이다.
function distributeSupplierComp(address cToken, address suplier, bool distributeAll) internal { CompMarketState storage supplyState = compSupplyState[cToken]; Double memory supplyIndex = Double({mantissa: supplyState.index}); Double memory suplierIndex = Double({mantissa: compSupplierIndex[cToken][supplier]}); compSupplierIndex[cToken][supplier] = supplyIndex.mantissa; if (supplierIndex.mantissa == 0 && supplyIndex.mantissa > 0) { supplierIndex.mantissa = compInitialIndex; } Double memory deltaIndex = sub_(supplyIndex, supplierIndex); uint supplierTokens = CToken(cToken).balanceOf(supplier); uint supplierDelta = mul_(supplierTokens, deltaIndex); uint supplierAccrued = add_(compAccrued[supplier], supplierDelta); compAccrued[supplier] = transferComp(supplier, supplierAccrued, distributeAll ? 0 : compClainThreshold); emit DistributedSupplierComp(CToken(cToken), supplier, supplierDelta, supplyIndex.mantissa); }
여기서는 supplyState를 보고 시스템에 기여하는 borrower, supplier들에게 COMP 토큰을 나눠준다. 그러니까, 돈을 빌려도 이자로 CToken은 불어나는데, COMP 토큰을 받을 수 있다. 그래서 가끔 Distributed APR이라고 돈을 빌려도 돈을 받는 경우가 있는데, 수령하는 COMP 토큰이 이자보다 많으면 돈을 빌려도 돈을 버는 상황이 발생하는 것 같다. 그런데 이게 이상하거나 사기라고 생각할 수도 있지만, 내 생각에는 이미 토큰 분배 계획이 다 공개돼있기 때문에, 어느정도 신뢰할 수 있는 것 같다.
그래서 저장한 supplyState를 이용하여 transferComp 함수를 통해 홀더들에게 COMP 토큰을 분배하는 로직이 작성돼있다. 마지막에는 DistributedSupplierComp 로그를 찍어준다. 그 후에 mintAllowed함수는 NO_ERROR를 리턴하고 종료한다.
이제 다시 mintFresh함수로 들어오자.
... ... if (accrualBlockNumber != getBlockNumber()) { return (fail(Error.MARKET_NOT_FRESH, FailureInfo.MINT_FRESHNESS_CHECK), 0); } ... ...
accrualBlockNumber는 accrueInterest() 함수 안에서 업데이트 된다. 그리고 함수 연산이 같은 트랜잭션 안에서 다 이루어질테니까 이것은 정상적으로 mint가 됐다면 당연히 통과해야 할 로직인 것 같다. 근데 그럼에도 불구하고 한번 더 체크하는 이유가 있는지는 좀 궁금하다.
... ... MintLocalVars memory vars; (vars.mathErr, vars.exchangeRateMantissa) = exchangeRateStoredInternal(); if (vars.mathErr != MathError.NO_ERROR) { return (failOpaque(Error.MATH_ERROR, FailureInfo.MINT_EXCHANGE_RATE_READ_FAILED, uint(vars.mathErr)), 0); } ... ... function exchangeRateStoredInternal() internal view returns (MathError, uint) { uint _totalSupply = totalSupply; if (_totalSupply == 0) { return (MathError.NO_ERROR, initialExchangeRateMantissa); } else { uint totalCash = getCashPrior(); uint cashPlusBorrowsMinusReserves; Exp memory exchangeRate; MathError mathErr; (mathErr, cashPlusBorrowsMinusReserves) = addThenSubUInt(totalCash, totalBorrows, totalReserves); if (mathErr != MathError.NO_ERROR) { return (mathErr, 0); } (mathErr, exchangeRate) = getExp(cashPlusBorrowsMinusReserves, _totalSupply); if (mathErr != MathError.NO_ERROR) { return (mathErr, 0); } return (MathError.NO_ERROR, exchangeRate.mantissa); } }
그 다음 exchangeRateStoredInternal() 함수를 통해 뭔가 가져오는데 뭔가 하고 봤더니, 담보 토큰과 CToken의 교환비율인 exchange rate를 계산하는데, 이자를 쌓지 않은 상태에서 계산한다고 한다. 그래서 구현부를 보면 getCashPrior()를 사용해서 cash를 가져온다. 그 외에 로직은 좀 간단한데, (totalCash + totalBorrows - totalReserves)를 계산해서 (컨트랙트 내 토큰 과 대출해준 토큰의 합)에서 예비량을 뺀 후에, 전체 공급량으로 나눈 값을 리턴한다. 즉, 전체 공급량 대비 현재 시중에 풀려있는 토큰의 비율을 리턴한다고 생각하면 되겠다.
// mintFresh ... ... vars.actualMintAmount = doTrasnferIn(minter, mintAmount); (vars.mathErr, vars.mintTokens) = divScalarByExpTruncate(vars.actualMintAmount, Exp({mantissa: vars.exchangeRateMantissa})); require(vars.mathErr == MathError.NO_ERROR, "MINT_EXCHANGE_CALCULATION_FAILED"); (vars.mathErr, vars.totalSupplyNew) = addUInt(totalSupply, vars.mintTokens); require(vars.mathErr == MathError.NO_ERROR, "MINT_NEW_TOTAL_SUPPLY_CALCULATION_FAILED"); (vars.mathErr, vars.accountTokensNew) = addUInt(accountTokens[minter], vars.mintTokens); require(vars.mathErr == MathError.NO_ERROR, "MINT_NEW_ACCOUNT_BALANCE_CALCULATION_FAILED"); totalSupply = vars.totalSupplyNew; accountTokens[minter] = vars.accountTokensNew; emit Mint(minter, vars.actualMintAmount, vars.mintTokens); emit Transfer(address(this), minter, vars.mintTokens); return (uint(Error.NO_ERROR), vars.actualMintAmount); ... ...
이제 mintFresh의 마지막 부분이다. totalSupply와 accountToken을 갱신하기 위한 마지막 작업이 주를 이룬다. 새로 토큰을 발행했기 때문에 totalSupply를 그만큼 늘려줘야 하고, 발행자의 잔고또한 그 만큼 늘려줘야 하기 때문이다. 특이한 점은 처음에 발행한 토큰을 doTransferIn이라는 함수를 통해 minter에게 보내는 것 같은데, 주석을 보면 다음과 같이 설명하고 있다.
- We call 'doTransferIn' for the minter and the mintAmount.
Note: The cToken must handle variations between ERC-20 and ETH underlying.
'doTransferIn' reverts if anything goes wrong, since we can't be sure if side-effects occurred.
The function returns the amount actually transferred, in case of a fee.
On success, the cToken holds an additional 'actualMintAmount' of cash.doTransferIn 함수는 뭔가 잘못됐을 경우, revert 작업을 한다고 설명하고 있다. CToken에는 함수 시그니처만 선언돼있고, 구체적인 구현은 CToken을 상속받는 CEther, CErc20과 같은 컨트랙트에 작성돼있다. CEther에 있는 doTransferIn 함수를 보면...
// CEther function doTransferIn(address from, uint amount) internal returns (uint) { require(msg.sender == from, "sender mismatch"); require(msg.value == amount, "value mismatch"); return amount; }
크게 하는 것은 없고 주소 체크정도 하는 것 같다. 그리고 실제로 transfer도 안한다.. 그럼 CErc20의 doTransferIn은 어떨까?
// CErc20.sol function doTransferIn(address from, uint amount) internal returns (uint) { EIP20NonStandardInterface token = EIP20NonStandardInterface(underlying); uint balanceBefore = EIP20Interface(underlying).balanceOf(address(this)); token.transferFrom(from, address(this), amount); bool success; assembly { switch returndatasize() case 0 { success := not(0) } case 32 { returndatacopy(0, 0, 32) success := mload(0) } default { revert(0, 0) } } require(success, "TOKEN_TRANSFER_IN_FAILED"); uint balanceAfter = EIP20Interface(underlying).balanceOf(address(this)); require(balanceAfter >= balanceBefore, "TOKEN_TRANSFER_IN_OVERFLOW"); return balanceAfter - balanceBefore; }
여기서는 로직도 생각보다 많고, 실제로 transferFrom함수를 통해 토큰 전송도 한다. 아마 그냥 이더와 erc20을 구분해야해서 이렇게 구현했나 싶기도 하다. 왜냐면 이더는 erc20은 아니니까..(근데 CEther는 erc20아닌가...아우 헷갈려)
로직은 다음과 같다. 먼저 caller의 토큰 밸런스를 가져오고, returndatasize를 통해 분기를 친다. returndatasize는 솔리디티의 내장 함수로, 가장 마지막 리턴 값의 크기를 의미한다고 한다. transferFrom 함수는 (EIP20NonStandardInterface 상에서) 아무 리턴값이 없고, 위에 transferFrom의 반환 값을 확인하는 것 같다. 보통 transferFrom 함수는 성공/실패 여부를 bool 타입으로 리턴하는데, 처음에 어? EIP20NonStandardInterface에 있는 transferFrom은 반환값이 없는데..? 라는 장벽에 부딪혔으나 곧 switch에 case 0가 그 경우를 커버하는 것을 깨달았다.
즉, case 0은 non-standard erc20 토큰 컨트랙트를 통해 들어오는 경우를 처리하는 것이고, case 32는 standard erc20 컨트랙트를 타고 들어오는 것이다. 그 외의 모든 경우는 에러라고 판단하고 revert를 하는 것 같다.require문과 revert는 뭐가 다를까? 보니까 opcode가 다른 것 같기는 한데, 작업을 롤백하고 스테이트를 복구하면서 남은 가스비를 환불하는 것 까지는 똑같은 것 같다. 다만 솔리디티 문서를 보니, require는 어떤 변수의 값, 파라미터의 값등을 체크하는데 사용하라고 하고, 그 외에 작업을 롤백해야 하는 경우에 revert를 사용하라고 나와있다. 여기의 경우는 어떤 변수를 체크하는 것이 아니라, 다른 함수의 동작을 체크하는 것에 가깝기 때문에 revert문을 사용한 것 같다.
그런데 글 쓰는 시점까지 해결되지 않는게 case 0일 때이다. case 32인 경우야 반환값 보고 데이터 카피해서 true / false여부를 success 변수에 반영한다 치는데, void transferFrom에서는 성공여부를 어떻게 판단할까? 이 문제를 좀 더 깊게 들여다보기 위해 (https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca) 이 미디움을 한번 읽어봤다. ERC20의 transfer쪽 함수에 버그가 있다고 설명하고 있는데, 내용은 다음과 같다.
- ERC20 표준의 transfer / transferFrom 함수들은 반환값을 갖도록 정의했는데, 많은 ERC20 토큰 컨트랙트가 아무 값도 반환하지 않고 있다! (미디움에는 Bad Token이라고 칭하고 있음)
그래서 여기서는 아무것도 리턴하지 않는 경우는 전송에 성공했다고 가정하고 있다. 아마 실패했다면 Non-erc20 transferFrom 내부의 require문 등에서 걸러졌을테니, 그렇다고 생각하는 것 같은데,, 사실 아직 감이 잘 안와서 더 찾아봐야 할 것 같다. 미디엄에도 보면 다음과 같은 코드가 있는것으로 보아...
library ERC20SafeTransfer { ... ... assembly { mstore(0x00, 0xff) if iszero(call(gas(), _tokenAddress, 0, add(msg, 0x20), msgSize, 0x00, 0x20)) { revert(0, 0) } switch mload(0x00) case 0xff { // token is not fully ERC20 compatible, didn't return anything, assume it was successful success := 1 ... ...
그래서 그냥 그러려니..하고 넘어가는게 맞나 싶기도 하다 ㅎ; (좋은 생각이 있으면 댓글로 남겨주세요..)
만약 여기까지 잘 실행됐다면, 발행한 토큰을 발행자에게 넘겨주는 것 까지 실행됐다고 보면 되겠다.(아 물론 엄격하게 아직 트잭 실행이 안끝나긴 함... 블록에도 안 담겼고..)
이제 다음 로직들은 상대적으로 간단하니 한번 쓱 흝고 마무리하려고 한다. 현재 시각 오전 6시 35분..// mintFresh (vars.mathErr, vars.mintTokens) = divScalarByExpTruncate(vars.actualMintAmount, Exp({mantissa: vars.exchangeRateMantissa})); require(vars.mathErr == MathError.NO_ERROR, "MINT_EXCHANGE_CALCULATION_FAILED"); (vars.mathErr, vars.totalSupplyNew) = addUInt(totalSupply, vars.mintTokens); require(vars.mathErr == MathError.NO_ERROR, "MINT_NEW_TOTAL_SUPPLY_CALCULATION_FAILED"); (vars.mathErr, vars.accountTokensNew) = addUInt(accountTokens[minter], vars.mintTokens); require(vars.mathErr == MathError.NO_ERROR, "MINT_NEW_ACCOUNT_BALANCE_CALCULATION_FAILED"); totalSupply = vars.totalSupplyNew; accountTokens[minter] = vars.accountTokensNew; emit Mint(minter, vars.actualMintAmount, vars.mintTokens); emit Transfer(address(this), minter, vars.mintTokens); return (uint(Error.NO_ERROR), vars.actualMintAmount);
이제 exchangeRate를 가지고 CToken 발행량을 구해주고, totalSupply에 더해준다. 그리고 CToken을 발행자에게 넘겨준다. (accountTokens[minter] = vars.accountTokensNew)
그리고 마지막으로 로그 찍어주고 실제 발행량 리턴해주면 끝! 헥헥.. 굉장히 길었는데 한번 mintFresh의 동작을 정리해보면
1. mintAllowed를 통해 몇 가지 체크를 함 (발행이 중단되지는 않았는지, 코인리스트에 포함돼있는지...)
2. doTransferIn 함수를 통해 담보 자산을 컨트랙트로 보낸다. 이 때 눈여겨볼만 한 것은 내부 구현을 통해 non-standard erc20 컨트랙트 까지 구현에 포함시키고, 실패시 모든 작업을 revert하는 점!
3. exchangeRate과 발행량을 통해, 새로 발행할 CToken의 양을 계산하여 발행자에게 전송하고, 총 공급량에 더한다.이렇게 하면 사용자가 담보 자산을 맡기고, CToken을 대출하는 상황을 이해할 수 있다. 너무 길었는데 이렇게 길 줄 몰랐다.. 그냥 눈으로만 슥 보면 금방 이해할 수 있을 것 같았는데.. line by line으로 뜯어보려다 보니 거의 밤을 세버렸다. 그래도 보람이 있고, 오픈제플린에 Audit 보고서가 많다는걸 처음 알았다. 창업을 준비하면서 사이드로 스마트 컨트랙트 보안 같은데 관심이 많았는데, 보고서를 유용하게 보면서 공부해야겠다는 생각이 들었다!
공부용으로 슥슥 보면서 작성한 글이라 두서가 없고 틀린 정보가 있을 수 있습니다. 댓글 부탁드릴게요!
'블록체인' 카테고리의 다른 글
근황..끄적끄적.... (0) 2022.03.08 Compound를 분석해보자 (3) - 컨트랙트 3편, Liquidation (0) 2021.05.30 Compound를 분석해보자 (1) - 컨셉 (1) 2021.04.11 [DeFi] Uniswap V2 Contract 코드 분석 - Periphery, Migrator (2) 2021.03.08 [Defi] Uniswap V2 Architecture 분석 (0) 2021.02.24 - totalBorrows : Total amount of outstanding borrows of the underlying in this market