-
Compound를 분석해보자 (3) - 컨트랙트 3편, Liquidation블록체인 2021. 5. 30. 07:38
이번에는 컴파운드 컨트랙트 중 청산과 관련된 코드를 보기로 했다.
컴파운드는 담보 대출 플랫폼이고, 담보 가치가 하락하면 담보를 청산시키는 시스템이 필요하다. 왜냐하면 대출로 나간 자산보다 담보의 가치가 작다는 것은 시스템에 부실이 발생했다는 뜻이기 때문이다.
그럼 바로 코드를 보면서 하나씩 뜯어보자. 컨트랙트는 아래 주소를 보면 되겠다.
https://github.com/compound-finance/compound-protocol/blob/master/contracts/CToken.sol/** * @notice The liquidator liquidates the borrowers collateral. * The collateral seized is transferred to the liquidator. * @param borrower The borrower of this cToken to be liquidated * @param liquidator The address repaying the borrow and seizing collateral * @param cTokenCollateral The market in which to seize collateral from the borrower * @param repayAmount The amount of the underlying borrowed asset to repay * @return (uint, uint) An error code (0=success, otherwise a failure, see ErrorReporter.sol), and the actual repayment amount. */ function liquidateBorrowFresh(address liquidator, address borrower, uint repayAmount, CTokenInterface cTokenCollateral) internal returns (uint, uint) { /* Fail if liquidate not allowed */ uint allowed = comptroller.liquidateBorrowAllowed(address(this), address(cTokenCollateral), liquidator, borrower, repayAmount); if (allowed != 0) { return (failOpaque(Error.COMPTROLLER_REJECTION, FailureInfo.LIQUIDATE_COMPTROLLER_REJECTION, allowed), 0); } /* Verify market's block number equals current block number */ if (accrualBlockNumber != getBlockNumber()) { return (fail(Error.MARKET_NOT_FRESH, FailureInfo.LIQUIDATE_FRESHNESS_CHECK), 0); } /* Verify cTokenCollateral market's block number equals current block number */ if (cTokenCollateral.accrualBlockNumber() != getBlockNumber()) { return (fail(Error.MARKET_NOT_FRESH, FailureInfo.LIQUIDATE_COLLATERAL_FRESHNESS_CHECK), 0); } /* Fail if borrower = liquidator */ if (borrower == liquidator) { return (fail(Error.INVALID_ACCOUNT_PAIR, FailureInfo.LIQUIDATE_LIQUIDATOR_IS_BORROWER), 0); } /* Fail if repayAmount = 0 */ if (repayAmount == 0) { return (fail(Error.INVALID_CLOSE_AMOUNT_REQUESTED, FailureInfo.LIQUIDATE_CLOSE_AMOUNT_IS_ZERO), 0); } /* Fail if repayAmount = -1 */ if (repayAmount == uint(-1)) { return (fail(Error.INVALID_CLOSE_AMOUNT_REQUESTED, FailureInfo.LIQUIDATE_CLOSE_AMOUNT_IS_UINT_MAX), 0); } /* Fail if repayBorrow fails */ (uint repayBorrowError, uint actualRepayAmount) = repayBorrowFresh(liquidator, borrower, repayAmount); if (repayBorrowError != uint(Error.NO_ERROR)) { return (fail(Error(repayBorrowError), FailureInfo.LIQUIDATE_REPAY_BORROW_FRESH_FAILED), 0); } ///////////////////////// // EFFECTS & INTERACTIONS // (No safe failures beyond this point) /* We calculate the number of collateral tokens that will be seized */ (uint amountSeizeError, uint seizeTokens) = comptroller.liquidateCalculateSeizeTokens(address(this), address(cTokenCollateral), actualRepayAmount); require(amountSeizeError == uint(Error.NO_ERROR), "LIQUIDATE_COMPTROLLER_CALCULATE_AMOUNT_SEIZE_FAILED"); /* Revert if borrower collateral token balance < seizeTokens */ require(cTokenCollateral.balanceOf(borrower) >= seizeTokens, "LIQUIDATE_SEIZE_TOO_MUCH"); // If this is also the collateral, run seizeInternal to avoid re-entrancy, otherwise make an external call uint seizeError; if (address(cTokenCollateral) == address(this)) { seizeError = seizeInternal(address(this), liquidator, borrower, seizeTokens); } else { seizeError = cTokenCollateral.seize(liquidator, borrower, seizeTokens); } /* Revert if seize tokens fails (since we cannot be sure of side effects) */ require(seizeError == uint(Error.NO_ERROR), "token seizure failed"); /* We emit a LiquidateBorrow event */ emit LiquidateBorrow(liquidator, borrower, actualRepayAmount, address(cTokenCollateral), seizeTokens); /* We call the defense hook */ // unused function // comptroller.liquidateBorrowVerify(address(this), address(cTokenCollateral), liquidator, borrower, actualRepayAmount, seizeTokens); return (uint(Error.NO_ERROR), actualRepayAmount); }
우선 첨부는 하지 않았지만, liquidateBorrowFresh는 liquidateBorrowInternal 함수에서 호출하며, 거기에서는 또 accrueInterest()를 호출하여 밀린 블록 만큼의 이자를 쌓는다.
그리고 liquidateVBorrowFresh함수는 다음과 같은 파라미터를 받고, 각 파라미터의 의미는,
- liquidator : 담보를 청산하는 사람의 주소
- borrower : 청산당하는 cToken 소유자의 주소
- repayAmount : 갚아야 할 금액인데, underlying이라는 것으로 보아 담보 자산의 양으로 입력을 받는 듯 하다.
- cTokenCollateral : 청산될 cToken 컨트랙트 주소
그리고 첫 번째 줄에서 comptroller의 liquidateBorrowAllowed 함수를 호출한다. 코드는 다음과 같다.
/** * @notice Checks if the liquidation should be allowed to occur * @param cTokenBorrowed Asset which was borrowed by the borrower * @param cTokenCollateral Asset which was used as collateral and will be seized * @param liquidator The address repaying the borrow and seizing the collateral * @param borrower The address of the borrower * @param repayAmount The amount of underlying being repaid */ function liquidateBorrowAllowed( address cTokenBorrowed, address cTokenCollateral, address liquidator, address borrower, uint repayAmount) external returns (uint) { // Shh - currently unused liquidator; if (!markets[cTokenBorrowed].isListed || !markets[cTokenCollateral].isListed) { return uint(Error.MARKET_NOT_LISTED); } /* The borrower must have shortfall in order to be liquidatable */ (Error err, , uint shortfall) = getAccountLiquidityInternal(borrower); if (err != Error.NO_ERROR) { return uint(err); } if (shortfall == 0) { return uint(Error.INSUFFICIENT_SHORTFALL); } /* The liquidator may not repay more than what is allowed by the closeFactor */ uint borrowBalance = CToken(cTokenBorrowed).borrowBalanceStored(borrower); uint maxClose = mul_ScalarTruncate(Exp({mantissa: closeFactorMantissa}), borrowBalance); if (repayAmount > maxClose) { return uint(Error.TOO_MUCH_REPAY); } return uint(Error.NO_ERROR); }
이 함수에서는 실행하려는 청산 작업이 실제로 실행 가능한지 여부를 체크한다.
먼저 if (!markets[cTokenBorrowed]... 조건문을 통해서 청산하려는 cToken이 실제 컴파운드 마켓에 리스팅 됐는지를 본다. 그 다음은 getAccountLiquidityInternal(borrower) 함수를 부르는데, 이 안에서는 또 getHypotheticalAccountLiquidityInternal 함수를 부른다. Comptroller 컨트랙트에서 가장 중요하지 않을까 싶은 함수인데, 어떤 일을 하는지 살짝 봤더니 유저의 balance를 갱신하는 듯 하다.
/** * @notice Determine what the account liquidity would be if the given amounts were redeemed/borrowed * @param cTokenModify The market to hypothetically redeem/borrow in * @param account The account to determine liquidity for * @param redeemTokens The number of tokens to hypothetically redeem * @param borrowAmount The amount of underlying to hypothetically borrow * @dev Note that we calculate the exchangeRateStored for each collateral cToken using stored data, * without calculating accumulated interest. * @return (possible error code, hypothetical account liquidity in excess of collateral requirements, * hypothetical account shortfall below collateral requirements) */ function getHypotheticalAccountLiquidityInternal( address account, CToken cTokenModify, uint redeemTokens, uint borrowAmount) internal view returns (Error, uint, uint) { AccountLiquidityLocalVars memory vars; // Holds all our calculation results uint oErr; // For each asset the account is in CToken[] memory assets = accountAssets[account]; for (uint i = 0; i < assets.length; i++) { CToken asset = assets[i]; // Read the balances and exchange rate from the cToken (oErr, vars.cTokenBalance, vars.borrowBalance, vars.exchangeRateMantissa) = asset.getAccountSnapshot(account); if (oErr != 0) { // semi-opaque error code, we assume NO_ERROR == 0 is invariant between upgrades return (Error.SNAPSHOT_ERROR, 0, 0); } vars.collateralFactor = Exp({mantissa: markets[address(asset)].collateralFactorMantissa}); vars.exchangeRate = Exp({mantissa: vars.exchangeRateMantissa}); // Get the normalized price of the asset vars.oraclePriceMantissa = oracle.getUnderlyingPrice(asset); if (vars.oraclePriceMantissa == 0) { return (Error.PRICE_ERROR, 0, 0); } vars.oraclePrice = Exp({mantissa: vars.oraclePriceMantissa}); // Pre-compute a conversion factor from tokens -> ether (normalized price value) vars.tokensToDenom = mul_(mul_(vars.collateralFactor, vars.exchangeRate), vars.oraclePrice); // sumCollateral += tokensToDenom * cTokenBalance vars.sumCollateral = mul_ScalarTruncateAddUInt(vars.tokensToDenom, vars.cTokenBalance, vars.sumCollateral); // sumBorrowPlusEffects += oraclePrice * borrowBalance vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.oraclePrice, vars.borrowBalance, vars.sumBorrowPlusEffects); // Calculate effects of interacting with cTokenModify if (asset == cTokenModify) { // redeem effect // sumBorrowPlusEffects += tokensToDenom * redeemTokens vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.tokensToDenom, redeemTokens, vars.sumBorrowPlusEffects); // borrow effect // sumBorrowPlusEffects += oraclePrice * borrowAmount vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.oraclePrice, borrowAmount, vars.sumBorrowPlusEffects); } } // These are safe, as the underflow condition is checked first if (vars.sumCollateral > vars.sumBorrowPlusEffects) { return (Error.NO_ERROR, vars.sumCollateral - vars.sumBorrowPlusEffects, 0); } else { return (Error.NO_ERROR, 0, vars.sumBorrowPlusEffects - vars.sumCollateral); } }
주석을 보면, 대출을 실행하거나 상환할 때 사용자의 계좌 유동성을 다시 계산한다고 나와있다. 파라미터를 보면, 사용자의 주소인 account, 계산에 이용될 cToken인 cTokenModify, 상환 / 대출되는 토큰 수인 redeemTokens, borrowAmount이 필요하다. 반환형은 Error, uint, uint 3가지를 반환해야 하는데, 첫 번째는 에러가 발생했을때 넣는 에러고, 두 번째 파라미터와 세 번재 파라미터는 각각 "담보 대비 초과분", "담보 대비 부족분"을 뜻한다고 한다. 코드를 보면서 이해해보자..
AccountLiquidityLocalVars라는 구조체를 이용하는데, 한번 찾아보니까 되게 많은 변수들을 저장하고 있더라.
/** * @dev Local vars for avoiding stack-depth limits in calculating account liquidity. * Note that `cTokenBalance` is the number of cTokens the account owns in the market, * whereas `borrowBalance` is the amount of underlying that the account has borrowed. */ struct AccountLiquidityLocalVars { uint sumCollateral; uint sumBorrowPlusEffects; uint cTokenBalance; uint borrowBalance; uint exchangeRateMantissa; uint oraclePriceMantissa; Exp collateralFactor; Exp exchangeRate; Exp oraclePrice; Exp tokensToDenom; }
몇 개는 직관적으도 알겠는데 다른것들은 잘 모르겠다.. 요건 코드를 보면서 하나씩 봐야할 듯!
다시 getHypoticalAccountLiquidityInternal 함수로 돌아와서, accountAssets[account] 코드를 이용하여 이 주소가 갖고 있는 모든 cToken들을 가져온다. 그리고 반복문을 통해 모든 에셋에 대해서 연산을 진행하는데, 궁금한 것은 왜 굳이 한 가지 에셋에 대해서 진행하는게 아니라 모든 에셋에 대해 진행하는지 여부이다.
뭐 어쨋든 루프 안으로 들어와서 코드를 보면, 먼저 계정 상태 스냅샷을 가져온다. (asset.getAccountSnapshot(account) 함수 이용) 그리고 collateralFactor(담보 비율), exchangeRate(cToken과 담보의 교환 비율), oraclePriceMantissa(오라클 가격)을 가져와서 저장해둔다. 오라클 가격은 getUnderlyingPrice 함수에서 가져오는데, SimplePriceOcacle 컨트랙트가 있어서 나중에 한번 살펴봐야겠다.
우선 오라클 가격이 0일때 에러로 처리한다. SimplePriceOracle 구현부를 보면 가격을 mapping 타입으로 관리하는데, key가 없거나 에러가 나면 기본값인 0을 반환하는 것으로 알고있다. 그래서 0을 받으면 에러라고 생각하고 처리한다고 보면 될 것 같다.
오라클 가격을 제대로 가져왔으면, AccountLiquidityLocalVars 에다가 저장해두고, tokensToDenom, sumCollateral, sumBorrowPlusEffects는 다음과 같이 계산한다.
- tokensToDenom = collateralFactor * exchangeRate * oraclePrice
- sumCollateral = sumCollateral + (tokensToDenom * cTokenBalance)
- sumBorrowPlusEffects = sumBorrowPlusEffects + (oraclePrice * borrowBalance)
tokensToDenom은 담보 비율을 ether 가치로 환산한 값이다. 담보 비율에 교환 비율을 곱해서 받아오는 ether의 개수를 구하고, 오라클 가격을 곱해서 가치로 환산한다. 그런데 계산식 자체가 담보의 양이 들어간게 아닌, 담보 비율이 들어갔기 때문에 Denom이라는 단어를 붙였다. 이 변수는 바로 다음에 전체 담보 가치를 구하는데 사용된다.
sumCollateral은 위에서 구한 tokensToDenom에다가 cTokenBalance를 곱해서 기존에 sumCollateral에다가 더한다. cTokenBalance는 코드 상단에 작성돼 있듯이, account snapshot에 있는 cToken의 양을 가져온다. 처음에는 왜 계속 누산하는지 이해가 안됐는데, 잘 생각해보니 var에 저장된 모든 값은 처음에 0이고, 내가 빌리거나 공급한 모든 자산들을 돌면서 ether 가치로 환산한 다음 합하는걸 보니까 그냥 내가 컴파운드와 상호 작용한 모든 토큰의 가치를 합해버린다고 이해하면 될 것 같다.
sumBorrowsPlusEffects는 oracle가격에 대출 잔량을 곱한 값을 다시 가산한다. 이것도 마찬가지로 대출한 토큰의 가치를 누적해나가는 것 같은데, 위와 비슷하게 생각하면 될 것 같다.
처음에 왜 전체 에셋 리스트를 도는지 살짝 궁금했는데 그 다음 라인에서 궁금증이 좀 풀렸다. if (asset == cTokenModify) 조건문을 통해 실제 변화가 일어나는 토큰인 경우에만 실행되는 로직을 정의했다. 안에서 어떤 로직이 돌아가는지 하나씩 살펴보면, 파라미터로 들어온 redeemTokens와 borrowAmount값을 실제로 이용한다.
- sumBorrowPlusEffects = sumBorrowPlusEffects + (tokensToDenom * redeemTokens)
- sumBorrowPlusEffects = sumBorrowPlusEffects + (oraclePrice * borrowAmount)
redeemTokens는 상환한 토큰의 양인데, 토큰의 양으로 들어온 인풋에는 tokensToDenom을 이용해 가치로 환산해주고, borrowAmount같은 경우는 들어오는 인풋의 기준이 underlying asset의 가치로 들어오는(주석에...) 것 같아서, oracle price를 곱해 가치로 환산한다.
어쨋든 이런 작업을 통해서 자산들의 유동량을 한번 업데이트 해주고, 루프가 끝난 다음에는 간단한 분기를 통해 값을 반환한다. 분기는 sumCollateral값과 sumBorrowPlusEffects값을 비교하는데, sumCollateral은 담보의 합이라는건 알겠지만 sumBorrowPlusEffects가 뭔지 확실하게 알고 넘어가야겠다. (아직 뭔가 애매...)
sumBorrowPlusEffects가 쓰이는 수식들을 보면 다음과 같다.
- (기존 대출량 x 대출한 토큰의 오라클 가격)을 누산
- (상환량 x 정규화한 토큰 가치)를 누산
- (추가 대출량 x 대출한 토큰의 오라클 가격)을 누산
보니까 그냥 대출된 토큰의 양을 가치로 환산한 것 같다..그럼 분기에서 비교하는 것은 대출량이 담보량보다 큰지, 작은지를 판단하는 것 같다.
만약 담보가 대출보다 크다면(vars.sumCollateral > vars.sumBorrowPlusEffects) 그 차이를 2번째 파라미터에 두고 반환한다. 두 번째 파라미터가 갖는 의미는 "Hypothetical account liquidity in excess of collateral requirements", 대출분을 초과한 담보분(경제 베이스가 전혀 없어서 말을 잘 못하겠다 ㅠㅠ)을 반환한다고 보면 되겠다.
반대의 경우는 3번째 파라미터에 두 값의 차를 넣어서 반환하고, 3번째 파라미터의 의미는 "Hypothetical account shortfall below collateral requirements" 라고 한다. 즉, 담보대비 부족분? 정도로 보면 될 것 같다. 왜냐면 대출, 상환값을 받아서 유저의 대출 상태를 업데이트 했는데, 대출량이 담보보다 커져버린 상황이니까.
이 이갸니는 getHypotheticalAccountLiquidityInternal 함수를 호출하는 부분을 보면 더 명확하게 알 수 있다. 3번째 반환 값을 shortfall 이라는 변수에 받는데, shortfall > 0 인경우는 전부 에러 처리를 해버린다.
그런데 청산의 경우는 다르다. 청산은 이 shortfall이 0보다 커져서, 대출량이 담보보다 커져버린 경우에 발생하기 때문에 shortfall == 0인 경우에 오히려 청산이 불가능한 상황이라고 에러를 반환한다. 아래 코드를 참고하자.
/* The borrower must have shortfall in order to be liquidatable */ (Error err, , uint shortfall) = getAccountLiquidityInternal(borrower); if (err != Error.NO_ERROR) { return uint(err); } if (shortfall == 0) { return uint(Error.INSUFFICIENT_SHORTFALL); }
위의 코드도 liquidateBorrowAllowed의 한 부분이니, shortfall == 0인 경우, 즉 대출자의 담보보다 대출량이 많아져버린 경우라고 가정하고 그 아래 로직들을 한번 슥슥 봐야겠다.
/* The liquidator may not repay more than what is allowed by the closeFactor */ uint borrowBalance = CToken(cTokenBorrowed).borrowBalanceStored(borrower); uint maxClose = mul_ScalarTruncate(Exp({mantissa: closeFactorMantissa}), borrowBalance); if (repayAmount > maxClose) { return uint(Error.TOO_MUCH_REPAY); } return uint(Error.NO_ERROR);
이제 대출자의 대출량을 가져온 다음, closeFactor와 곱해주는데 우선 closeFactor가 뭘 하는 녀석인지 한번 다시 생각해보자.(ComptrollerStorage 컨트랙트 참고)
"Multiplier used to calculate the maximum repayAmount when liquidating a borrow"
즉, 청산할 때 최대 상환 금액을 결정하기 위해 쓰이는 승수. 대출자의 잔고에 closeFactor를 곱해서 나오는 금액이 청산 가능한 최대 금액이라고 보면 될 것 같다. 아마 한번 청산하고나서도 담보 가치가 낮다면, 맞춰질때 까지 반복해서 청산한다고 들은 것 같은데 이거와 관련있다고 보면 될 듯 싶다.
그래서 만약 청산자가 repayAmount에다가 너무 큰 값을 넣어서 청산을 시도한다면, TOO_MUCH_REPAY 에러를 뱉는다. 이런 로직이 없다면 아마 청산자가 악의적으로 큰 금액을 청산시켜서 대출자를 엿먹이는(...) 상황도 가능할 것 같다.
여기까지 잘 실행됐다면 정상적으로 청산이 가능하다고 판단하고 NO_ERROR를 반환한다. 기억하자,, 지금까지 liquidateBorrowAllowed였다..
다시 CToken 컨트랙트의 liquidateBorrowFresh 함수로 돌아와서, 에러가 발생하지 않으면 여러가지 체크를 한다. block number가 정확한지 체크하고, 대출자와 청산자가 서로 다른 사람인지 체크하고(상식적으로 대출자가 청산을 한다는게 말이 안되긴 함... 되게 꼼꼼히 체크한다는 생각이 들었다.) repayAmount, 즉 청산 금액이 0이 아닌지 확인하고, -1이 아닌지도 확인한다. 여기까지 통과했다면 repayBorrowFresh함수를 통해 청산을 진행한다. 이 함수는 다음 글에서 좀 뜯어보려고 한다. 대출을 대신 상환하는 로직이니까, 청산자가 토큰을 컨트랙트에 보내주는 로직이 작성돼있을 것 같다.
그 다음은 seize하는 담보 자산의 양을 계산하는 로직이 있는데,, 이것도 다음 글에 좀 써보려고 한다. 글이 너무 길어지것 같아서,, 아마 MakerDAO도 그렇고 강제 청산을 당하는 경우에 청산 이외에 패널티가 부과된다고 하는데, seize가 압류한다는 뜻도 있고 해서 repay와 별개로 청산자가 가져가는 로직이 작성돼있을 것 같다.
어쨋든 이렇게 liquidateAllowed -> repayBorrow -> seizeToken 까지의 과정을 모두 거치게되면 청산이 완료된다. 시간도 너무 늦고 졸려서 청산 1편으로 좀 줄여보려고 하는데 repay와 seize쪽 로직도 빨리 파봐야겠다.
'블록체인' 카테고리의 다른 글
근황..끄적끄적.... (0) 2022.03.08 Compound를 분석해보자 (2) - 컨트랙트 1편, Borrowing (0) 2021.05.02 Compound를 분석해보자 (1) - 컨셉 (1) 2021.04.11 [DeFi] Uniswap V2 Contract 코드 분석 - Periphery, Migrator (2) 2021.03.08 [Defi] Uniswap V2 Architecture 분석 (0) 2021.02.24