반응형

들어가기에 앞서 ...

Compound v2에서 사용되는 여러 helper function들의 로직을 정리한 포스팅이다. 사실 Lending 프로토콜에서 가장 중요한 것은 사용자들이 직접 호출하는 entry point라고도 할 수 있겠지만, 실제로 사용자들의 자금을 관리, 이자를 업데이트, 프로토콜에 대한 관리 등을 수행하는 것은 이러한 helper function들이 있기에 가능한 것이기 때문에 따로 글을 빼 포스팅을 작성하였다.

목차 (Helper functions)

  • CToken::accrueInterest()
  • … (계속해서 추가해나갈 예정이다)

CToken::accrueInterest()

function accrueInterest() virtual public returns (uint);

역할

cToken의 borrow 및 deposit 자산에 대해 시간이 지남에 따라 발생하는 이자를 계산하고, 이를 기반으로 잔액, 이자율 및 관련 지표(borrowIndex, totalBorrows)를 최신 상태로 업데이트하는 역할을 한다. 

주요 기능들에서 로직을 실행하기 이전에 accrueInterest 함수를 실행하면서 시작한다.

변수 Update

  • accrualBlockNumber
  • borrowIndex
  • totalBorrows
  • totalReserves

로직 deep dive

accrueInterest() - 1

function accrueInterest() public returns (uint) {
    uint currentBlockNumber = getBlockNumber();
    uint accrualBlockNumberPrior = accrualBlockNumber;

    if (accrualBlockNumberPrior == currentBlockNumber) {
        return NO_ERROR;
    }
  1. ethereum mainnet의 compound v2는 block.timestamp가 아닌, block.number를 기반으로 이자 등 관련 지표를 관리한다. 따라서 이전 업데이트 이후 발생한 이자를 계산하기 위해 현재 block.number값을 currentBlockNumber에 받아온다.
  2. 전역으로 저장된 가장 최근에 Index가 업데이트된 block.number를 accrualBlockNumberPrior변수에 저장한다.
  3. currentBlockNumber == accrualBlockNumberPrior 를 비교하여 같다면 return을 시켜준다.
    block number를 기반으로 state 업그레이드가 발생하기 때문에 같은 block에서 이미 update가 되었다면 여러 index들을 새로 업데이트해줄 필요가 없기 때문.

문득, (3)의 내용이 gas를 얼마나 줄여주는지 궁금해져 foundry testcode를 작성해 테스트를 진행해보았다.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console} from "forge-std/Test.sol";

contract CounterTest is Test {
    testCompoundV2Gas target;

    function setUp() public {
        vm.createSelectFork("eth_mainnet");
        target = new testCompoundV2Gas();
    }

    function test_compoundV2GasOp() public {
        console.log(target.getGasAmount());
        console.log(target.getGasAmount());
    }
}

contract testCompoundV2Gas {
		// cETH address
    address cETH= 0x4Ddc2D193948926D02f9B1fE9e1daa0718270ED5;
    
    function getGasAmount() public returns (uint gasUsed) {
        gasUsed = gasleft();
        (bool suc, ) = cETH.call(abi.encodeWithSignature("totalBorrowsCurrent()"));
        require(suc, "function call failed");

        gasUsed = gasUsed - gasleft();
    }
}

Result

test 결과를 통해 알 수 있듯, 약 40000 wei 즉, $0.7884 (24.11.16 기준)의 gas 최적화가 되는 것을 알 수 있다.

 

계속 해보자.

accrueInterest() - 2

uint cashPrior = getCashPrior(); // cToken컨트랙트의 underlying balance
uint borrowsPrior = totalBorrows; // underlying token을 user들이 borrow한 총량
uint reservesPrior = totalReserves; // 총 준비금
uint borrowIndexPrior = borrowIndex; // 현재 borrowIndex

이와 같은 네 개의 memory변수에 state값들을 저장하는 것을 확인해볼 수 있다. 자세한 내용은 주석 참고

function getCashPrior() internal view returns (uint) {
    EIP20Interface token = EIP20Interface(underlying);
    return token.balanceOf(address(this));
}

getCashPrior는 이와 같이 cToken 컨트랙트의 underlying token 잔액(balance)를 불러온다.

 

accrueInterest() - 3

uint borrowRateMantissa = interestRateModel.getBorrowRate(cashPrior, borrowsPrior, reservesPrior);
require(borrowRateMantissa <= borrowRateMaxMantissa, "borrow rate is absurdly high");

여러 변수들을 업데이트 해주기 위해 compound v2는 현재의 underlying balance, 빌린 금액, 준비금 총액을 이용해 borrowRate를 계산해주게 된다.

interestRateModel::borrowRate()함수를 살펴보자.

function getBorrowRate(uint cash, uint borrows, uint reserves) public view returns (uint) {
    uint ur = utilizationRate(cash, borrows, reserves);
    return (ur * multiplierPerBlock / BASE) + baseRatePerBlock;
}

utilizationRate 즉, 활용율을 계산한 뒤 여러 가중치를 반영한 borrowRate를 리턴해주는 함수이다.

활용율은 전체 예치(deposit)된 자산 중, borrow에 쓰인 비율을 나타내는 비율이라고 알면 된다. 아래 코드를 보면 바로 이해가 될 것이다.

function utilizationRate(uint cash, uint borrows, uint reserves) public pure returns (uint) {
    // Utilization rate is 0 when there are no borrows
    if (borrows == 0) {
        return 0;
    }

    return borrows * BASE / (cash + borrows - reserves);
}

이러한 활용율에 multiplierPerBlock(자산 이용율에 이자율의 증가 속도를 제어하는 상태변수) 를 곱한 후, 최소 이자율인 baseRatePerBlock을 더해주어 최종적으로 borrowRate를 반환해준다.

이렇게 얻은 borrowRate는 borrowRateMax값보다 작아야함을 검사하며 조건검사를 끝낸다.

 

accrueInterest() - 4

uint blockDelta = currentBlockNumber - accrualBlockNumberPrior;
Exp memory simpleInterestFactor = mul_(Exp({mantissa: borrowRateMantissa}), blockDelta);
uint interestAccumulated = mul_ScalarTruncate(simpleInterestFactor, borrowsPrior);
uint totalBorrowsNew = interestAccumulated + borrowsPrior;
uint totalReservesNew = mul_ScalarTruncateAddUInt(Exp({mantissa: reserveFactorMantissa}), interestAccumulated, reservesPrior);
uint borrowIndexNew = mul_ScalarTruncateAddUInt(simpleInterestFactor, borrowIndexPrior, borrowIndexPrior);

다소 복잡하고 어질어질해보이는데, 각 internal function들에 대해 가볍게 짚고 넘어가보는걸로 해보자.

 

피연산자(A, B, C)

  • mul_(A, B): A * B
  • mul_ScalarTruncate(A, B): A * B / BASE (BASE스케일만큼 스케일링)
  • mul_ScalarTruncateAddUInt(A, B, C): A * B / BASE + C

이를 이용해 위 계산식들을 수식으로 표현해보면,

uint blockDelta = currentBlockNumber - accrualBlockNumberPrior;
Exp memory simpleInterestFactor = borrowRateMantissa * blockDelta
uint interestAccumulated = simpleInterestFactor * borrowsPrior / BASE
uint totalBorrowsNew = interestAccumulated + borrowsPrior;
uint totalReservesNew = reserveFactorMantissa * interestAccumulated + reservesPrior
uint borrowIndexNew = (simpleInterestFactor + 1) * borrowIndexPrior

이와 같이 나타낼 수 있다. 이제 로직을 살펴보자.

  • blockDelta: 이전 accrueInterest시점으로부터 지금까지 지난 block 수를 계산한다.
  • simpleInterestFactor: borrowRate에 blockDelta를 곱해 UtilizationRate에 따른 이자율에 시간 스케일을 반영한 값을 계산한다.
  • interestAccumulated: simpleInterestFactor에 borrow된 총량을 곱해주어 현재까지 대출된 총액에 추가로 발생한 이자를 반영해준다. 이를 통해 누적된 이자를 계산한다.
  • totalBorrowsNew: interestAccumulated에 borrowsPrior를 더해 총 부채를 업데이트해준다.
  • totalReservesNew: 준비금을 업데이트해준다. 여기서 사용된 reserveFactorMantissa값은 거버넌스에 의해 설정된 값이다.
  • borrowIndexNew: 마지막으로, borrowIndex를 simpleInterestFactor만큼의 증가분을 반영하여 업데이트 해준다.

accrueInterest() - 5

accrualBlockNumber = currentBlockNumber;
borrowIndex = borrowIndexNew;
totalBorrows = totalBorrowsNew;
totalReserves = totalReservesNew;

emit AccrueInterest(cashPrior, interestAccumulated, borrowIndexNew, totalBorrowsNew);

return NO_ERROR;

 

마지막으로, 계산된 memory변수들을 state에 반영한다.

event로그를 발생시키면서, NO_ERROR를 반환하여 로직을 종료한다.

아래는 전체 코드이다.

accrueInterest()

function accrueInterest() virtual override public returns (uint) {
    /* Remember the initial block number */
    uint currentBlockNumber = getBlockNumber();
    uint accrualBlockNumberPrior = accrualBlockNumber;

    /* Short-circuit accumulating 0 interest */
    if (accrualBlockNumberPrior == currentBlockNumber) {
        return NO_ERROR;
    }

    /* Read the previous values out of storage */
    uint cashPrior = getCashPrior();
    uint borrowsPrior = totalBorrows;
    uint reservesPrior = totalReserves;
    uint borrowIndexPrior = borrowIndex;

    /* Calculate the current borrow interest rate */
    uint borrowRateMantissa = interestRateModel.getBorrowRate(cashPrior, borrowsPrior, reservesPrior);
    require(borrowRateMantissa <= borrowRateMaxMantissa, "borrow rate is absurdly high");

    /* Calculate the number of blocks elapsed since the last accrual */
    uint blockDelta = currentBlockNumber - accrualBlockNumberPrior;

    /*
     * Calculate the interest accumulated into borrows and reserves and the new index:
     *  simpleInterestFactor = borrowRate * blockDelta
     *  interestAccumulated = simpleInterestFactor * totalBorrows
     *  totalBorrowsNew = interestAccumulated + totalBorrows
     *  totalReservesNew = interestAccumulated * reserveFactor + totalReserves
     *  borrowIndexNew = simpleInterestFactor * borrowIndex + borrowIndex
     */

    Exp memory simpleInterestFactor = mul_(Exp({mantissa: borrowRateMantissa}), blockDelta);
    uint interestAccumulated = mul_ScalarTruncate(simpleInterestFactor, borrowsPrior);
    uint totalBorrowsNew = interestAccumulated + borrowsPrior;
    uint totalReservesNew = mul_ScalarTruncateAddUInt(Exp({mantissa: reserveFactorMantissa}), interestAccumulated, reservesPrior);
    uint borrowIndexNew = mul_ScalarTruncateAddUInt(simpleInterestFactor, borrowIndexPrior, borrowIndexPrior);

    /////////////////////////
    // EFFECTS & INTERACTIONS
    // (No safe failures beyond this point)

    /* We write the previously calculated values into storage */
    accrualBlockNumber = currentBlockNumber;
    borrowIndex = borrowIndexNew;
    totalBorrows = totalBorrowsNew;
    totalReserves = totalReservesNew;

    /* We emit an AccrueInterest event */
    emit AccrueInterest(cashPrior, interestAccumulated, borrowIndexNew, totalBorrowsNew);

    return NO_ERROR;
}

 

 

이후에 계속 helper function 들을 추가해야겠다.

반응형
반응형

들어가기에 앞서..

이번 편에서는 compound v2의 전반적인 구조를 알아보고자 한다.

1편과 다소 순서가 뒤바뀐 면이 없지않아 있는 것 같긴 한데, 1편의 내용은 compound에서 사용되는 핵심적인 개념들에 대한 설명이었기 때문에 필수적이었고, 이 또한 전체적인 구조를 파악할 수 있기 때문에 필수적인 것 같다. 그래서 어느 것이 먼저인지는 중요하지 않은 것 같다. 이번 편에서 전체적인 구조를 살펴보고, 다음편 부터 코드레벨까지 뜯어보며 프로토콜 전반에 대한 내용을 살펴보도록 하자.

시작해보자.


Compound v2 구조

출처:  https://deephigh.gitbook.io/deephigh/architecture/compound

위 그림은 업사이드아카데미 1기 - DeepHigh팀으로 활동하면서 만든 Compound v2의 전체적인 호출 flow이다. 위 도식에는 Compound v2를 구성하는

  1. external entities
  2. internal storage
  3. contract 및 function

의 여러 동작flow가 담겨 있다. 모든 flow에서 어떻게 동작하는지에 대한 조금 더 자세한 내용을 살펴보기 위해 일단 end user들과 가장 가까운 파트부터 보는 것이 가장 직관적으로 와닿을 것 같아 그렇게 순서를 구성하였다.

순서

  1. 전체 구조 Overview
  2. cToken
  3. Comptroller
  4. Governance
  5. Price Oracle

전체 구조 Overview

Compound v2의 핵심적인 기능들은 아래와 같이 구성되어있다고 볼 수 있다.

근거

  • cToken: 사용자가 예치한 자산에 대한 권리 & 예치 자산에 대한 이자 수익 반영하는 토큰.
    • CErc20/cEther: cToken의 서로 다른 두 형태
    • cToken: 대출, 예치, 청산 등 기능을 수행하는 컨트랙트
  • Comptroller: 대출, 예치, 청산 등 기능을 관리하고 담보 비율 및 이자율 모델 등을 조정하는 중앙 관리 역할.
    • Unitroller: Comptroller의 로직을 분리하여 업그레이드 가능하게 만든 프록시 컨트랙트
    • Comptroller: 본체
  • Governance: Comp 토큰을 보유한 사용자들이 프로토콜의 주요 정책(이자율 모델, 담보 요건, 새로운 자산 추가 등)을 propose하고 vote할 수 있도록 하는 역할.
    • Comp: 거버넌스 토큰. 보유자는 propose 및 vote에 참여해 프로토콜 운영에 참여 가능.
    • Governor: 거버넌스의 핵심 컨트랙트. propose, vote 진행을 담당해 처리.
    • Timelock: 승인된 거버넌스 propose가 실행되기 전에 일정 시간 지연을 적용하는 컨트랙트
  • Price Feed: compound v2는 chainlink에서 feed data를 받아온다.

이제, 위의 각 구성요소들이 어떠한 역할&기능을 수행하는지 알아보도록 하자.


cToken

프로토콜에 예치(또는 공급)된 자산을 EIP-20표준에 맞춰 표현한 개념으로, Compound v2의 핵심 개념이다.

 

그래서 이걸로 뭐하는데

  1. cToken은 사용자가 공급한 자산의 가치를 나타낸다. 이를 통해 이자를 얻을 수 있다(예금)
  2. cToken을 담보로 대출 등 작업을 수행할 수 있다. (대출)

cToken은 Compound와 user가 상호작용하는 주요 수단이다.

사용자가 담보 입금(mint), 출금(redeem), 대출(borrow), 상환(repay), 청산(liquidate), 또는 전송(transfer)을 할 때 cToken 컨트랙트를 통해 이루어진다.

EIP-20표준을 따른다는 것을 통해 알 수 있듯, 내가 입금한 담보자산을 제3자에게 전송하여 제3자가 이를 이용해 대출 등의 작업을 할 수 있기도 하다.

CErc20/CEther

이들은 각각 cToken컨트랙트를 상속받는 user entry point이다. 컨트랙트의 주요 기능에 대한 Overview를 살펴보자.

functions visibility description
mint external 담보 입급하여 cToken발행
redeem external cToken소각하여 담보 인출(cToken기준)
redeemUnderlying external cToken소각하여 담보 인출(underlying기준)
borrow external cToken을 담보로 대출
repayBorrow external 대출받은 자산을 상환
repayBorrowBehalf external A가 대출받은 자산을 B가 상환
liquidateBorrow external A가 대출받은 자산을 C가 청산(청산보너스 get)
sweepToken external underlying이 아닌 token이 컨트랙트에 존재하면 해당 token을 sweep
doTransferIn internal EIP20 transferFrom
doTransferOut internal EIP20 transfer

두 컨트랙트 모두 위와 같은 기능들을 제공한다. 차이는 오직 Native ETH를 다루는가, ERC20 token을 다루는가의 차이.

예시

CErc20
CEther

cToken flow

end-user가 어떠한 entry point를 통해 프로토콜과 상호작용하는지 알아봤으니, cToken의 로직들이 어떻게 동작하는지 알아보자.

출처: https://deephigh.gitbook.io/deephigh

위의 그림을 보며, 예시로 mint함수를 들어 따라가보도록 하자. (다른 함수들도 다 똑같다.)

  1. cErc20/CEther를 통해 user가 mint(uint mintAmount)를 호출하면,
  2. mint는 internal call을 통해 mintInternal(mintAmount)를 호출한다.
  3. mintInternal는 accrueInterest()를 통해 다음 네 가지 state variable을 업데이트한다.
    1. borrowIndex: 이자계산에 사용되는 Index 업데이트
    2. totalBorrows: borrow된 총량 업데이트
    3. totalReserves: 준비금 업데이트
    4. accrualBlockNumber: 마지막으로 위의 값들을 업데이트한 block number
  4. 업데이트 이후, mintFresh를 통해 mint작업에 들어간다.
    1. comptroller.mintAllowed(address(this), minter, mintAmount)를 통해 minter가 mintAmount만큼 mint할 수 있는지 체크하고, CompSupplyIndex를 업데이트하며, Supplier들에게 comp를 distribute한다.
    2. 이후, 실제mint와 관련된 로직이 실행된다.

cToken의 다른 기능에 대한 로직(mint, redeem, borrow, repay)또한 위와같은 방식으로 실행된다.

하지만 liquidate에는 약간의 차이가 존재한다. 이 차이를 이해하기 위해서는 청산, 나아가 Compound의 청산에 대한 이해가 조금 필요하니 간단하게 짚고 넘어가보자.

liquidate 핵심만 빠르게

function liquidateBorrow(address borrower, uint repayAmount, CTokenInterface cTokenCollateral)

liquidate함수의 입력값은 1. 청산의 대상인 borrower, 2. 청산 규모인 repayAmount, 3. 담보자산의 cTokenInterface이 입력된다.

  1. 청산(liquidate)은 대출자(borrower)의 담보를 “싸게 파는”행위이며, 이를 통해 프로토콜의 재정적 안정성을 유지하고자 하는 기능이다. (처음 들으면 무슨말인지 잘 이해가 안된다. 일단 계속 읽자)
  2. borrower가 대출(borrow)을 할 때, 담보자산(cToken)들의 총 가치가 borrow하고자 하는 자산의 총 가치보다 커야한다.
  3. 담보자산의 가치가 빌린 자산의 가치보다 작아지는 현상(shortfall)이 발생하면, 청산의 대상이 된다.
  4. **청산인(liquidator)**은 이를 발견하고, cToken마켓에 liquidateBorrow를 실행하여 borrower의 **담보자산(cTokenCollateral)**을 청산한다.
  5. 이 때, repayAmount만큼의 “빌린 자산”을 청산하게 되고, repayAmount만큼의 가치에 할인율(liquidationIncentiveMantissa)을 곱한 수량의 담보자산(cTokenCollateral)을 그 대가로 받아 청산에 대한 보상을 얻게 된다.

그래서 liquidate는 어떤 flow로?

사실 flow자체는 비슷할 수 있는데, 위의 내용들을 토대로 하여, borrower가 빌린 자산과, 청산의 대상이 되는 borrower가 담보로 맡긴 자산 모두에 대해 이자율 업데이트 등 작업을 수행해야하는 것을 알 수 있다. 따라서, 그러한 차이가 존재한다.

실제 liquidate 로직 그리고 cToken 기능들에 대한 전반적인 세부사항은 다음에 올릴 포스팅에서 함께 확인해보도록 하자.

Comptroller

comptroller의 사전적 정의를 살펴보면, 재정 관리자라는 뜻으로 주로 사용되는 것 같다. Compound에서도 마찬가지이다.

Comptroller는 Compound v2의 리스크 관리계층으로, 사용자가 유지해야 할 담보비율을 결정하고, 사용자의 재무건전성을 판단한다. 재무건전성은 청산과 직접적으로 연관되며 사용자가 청산될 있는지, 그리고 얼마나 청산될 수 있는지를 판단하는 데 사용된다.

또한 사용자가 cToken과 상호작용할 때 마다 거래가 타당한지를 검토하는 데 사용된다.

Comptroller는 업그레이드 가능한 컨트랙트로, Unitroller라는 별도의 프록시컨트랙트에서 ComptrollerStorage와 Comptroller를 관리한다.

이제, 어떠한 기능들을 수행하는지 전체적으로 살펴보도록 하자.

Comptroller

enter/exitMarket(s)

function enterMarkets(address[] calldata cTokens)

함수 이름 그대로 market(들) 목록에 들어가는 함수이다. 특정 market에 예치한 자산이 담보로 인정되도록 하거나, 특정 market에서 자산을 borrow하기 위해 enterMarket을 해야 한다.

반대로 exitMarket은 이름 그대로 특정 market에서 exit하는 함수이다.

Allowed Functions

cToken컨트랙트에서 각종 Fresh함수들(mintFresh, redeemFresh, ….)은 내부적으로 accrueInterest()를 호출한 이후, comptroller의 ~Allowed 함수(mintAllowed, redeemAllowed, …)를 호출하게 된다. ~Allowed 함수에서는

  1. 해당 기능에 들어가는 여러 Input value를 검증하고
  2. updateCompSupplyIndex() 를 통해 보상과 관련된 Index를 업데이트하며
  3. distributeSupplierComp() 를 통해 supplier에게 comp를 분배한다.

Verify Functions

cToken컨트랙트에서 각종 Fresh함수들(mintFresh, redeemFresh, ….)은 내부적으로 ~Allowed 함수를 호출해 Input값들을 검증한 후, 함수 로직을 수행한 다음, 마지막 단계에서 ~verify함수를 호출한다.

현재 verify함수는 사용되지 않지만, 추후에 다시 사용될 수도 있기에.. 일단 기록한다.

mintFresh() 내부로직

각종 변수 및 유틸리티

  • collateralFactor이 값은 Governance에 의해 증/감 할 수 있다.
  • 기능이라기보다는 값에 가까운데, 공급한 담보자산의 실제 담보가치(대출 한도)의 비율을 나타내는 값이다. 대형자산(ETH 등), 유동성 높은 자산은 collateralFactor가 크고, 반대는 작다. 0%가 되면 해당 자산은 담보로서 활용할 수 없지만 borrow는 가능하다.
  • getAccountLiquidity
  • 특정 계정이 사용할 수 있는 유동성의 총 USD가치를 계산한다. 기본적으로 enterMarket을 통해 listing되어있는 담보 및 대출 자산에 대해서만 그 값을 계산한다. 또한, 사용할 수 있는 자산의 총량이 0보다 작아지면 shortfall이 생겨 해당 계정은 청산 가능한 상태에 도달하게 되고, 이에 따라 shortfall값도 리턴해준다.

Governance

Compound는 COMP토큰 보유자가 세 가지 컨트랙트(COMP, Governor Bravo, Timelock)를 컨트롤하며 관리 및 업그레이드된다.

이 컨트랙트들은 커뮤니티가 propose, vote를 통해 cToken, Comptroller의 관리기능을 통해 변경사항을 실행할 수 있도록 한다.

또한 COMP토큰 holder는 자신의 투표권을 다른 주소에 delegate할 수 있어 25,000 COMP이상 delegatae받은 주소는 제안을 propose할 수 있고, 누구든 100 COMP를 lock하여 propose를 생성할 수 있다.

Governance Timeline

출처: Compound

  • 최초 Propose시, 2일간의 review기간이 주어지며,
  • 이후 투표 가중치가 기록되고 투표가 시작된다.
  • 투표는 3일간 진행되고,
  • 다수이면서 최소 400,000표가 찬성하면 Timelock에 pending상태로 추가되고,
  • 해당 propose는 2일 뒤에 실행된다.

결과적으로, 프로토콜의 변경 사항은 최소 일주일이 소요된다.

Price Oracle

프로토콜의 여러 자산 가격 데이터를 Chainlink Price Feed를 통해 제공받는다.

이에 대한 코드베이스: https://github.com/smartcontractkit/open-oracle/blob/master/contracts/PriceOracle/PriceOracle.sol

  • multisig를 통해 price feed주소가 업데이트될 수 있음
  • getUnderlyingPrice함수를 통해 자산의 USD가격을 반환받을 때, 10^(36 - underlying.decimals())로 스케일 업된다. 예를들어 WBTC는 decimals가 8이므로, 반환값은 1e28이 됨.

여기까지 Compound v2의 기본적인, 그리고 전체적인 구조를 살펴보았다. 다음 글부터는 드디어 코드를 해체분석해보도록 하자.

반응형
반응형

들어가기에 앞서..

설명하지 않아야 할 부분까지 설명하는것은 귀찮으니 필요한 부분이나 빌드업을 위한 내용정도만 작성해보겠다.

이번 편에서는 compound v2에서 핵심 기능을 구현하기 위해 어떠한 내용을 고려했는지, 또 어떻게 구현했는지를 알아보도록 하자.

어떠한 방식으로 compound와 end-user가 소통하는지, 어떠한 수학적 로직이 사용되는지를 먼저 알아보고, 각종 기능들에 대해서는 다음편에서 알아보도록 하자.


Compound v2

compound프로토콜은 2019년에 출간된 Compound whitepaper(https://compound.finance/documents/Compound.Whitepaper.pdf)를 기반으로 하며, 오픈소스, 그리고 커뮤니티에 의해 관리된다. 이번 포스팅에서는 compound의 가장 기본적인 기능을 다루는 compound v2를 다뤄보도록 하겠다. 

제공하는 기능 오버뷰

  • Lending의 기본 기능
    • 담보입금(mint): 담보를 입금하고 이에 대한 cToken을 받는다
    • 담보출금(redeem): cToken을 돌려주고 이에 해당하는 양의 담보(underlying token)을 돌려받는다.
    • 대출(borrow): 담보의 가치(cToken의 가치)에 따라 LTV를 곱한 가치만큼의 자산을 빌릴 수 있다.
    • 상환(repay): 빌린 자산을 상환한다.
    • 청산(liquidation): 사용자가 borrow한 가치가 담보가치보다 커질 경우, 담보를 청산한다.

이외에도 거버넌스 등 기능이 있지만 그건 자세한 설명에서 다뤄도 괜찮을 것 같다.

 

아래 링크를 통해 실제 배포되어있는 각종 컨트랙트 주소들을 확인할 수 있다.

https://github.com/compound-finance/compound-config

 

GitHub - compound-finance/compound-config: Compound Protocol Configuration for Developers

Compound Protocol Configuration for Developers. Contribute to compound-finance/compound-config development by creating an account on GitHub.

github.com

Compound v2 Diagram

프로젝트를 진행하며 그렸던 compound v2의 함수호출 diagram이다.

출처:  https://deephigh.gitbook.io/

  1. 각 엔드유저들은 CErc20, CEther 컨트랙트를 통해 프로토콜과 상호작용하고,
  2. CErc20, CEther는 핵심 기능이 구현되어있는 cToken컨트랙트와 상호작용하여 각종 기능들을 수행한다.
  3. 또한 각 cToken들은 comptroller라고 불리는 프로토콜 관리자 컨트랙트와 상호작용하며 각종 조건검사 등을 수행하게 되는 과정으로 프로토콜이 작동하는 것을 확인해볼 수 있다.

compound v2의 구조에 대한 내용은 바로 다음 포스팅에서 더 자세히 살펴보도록 하자.

이번 포스팅에서는 Compound에서 사용되는 수학적 개념 등 가장 기초가 되는 내용들을 먼저 다루어보겠다.

Protocol Math

compound는 프로토콜에서 사용되는 각종 계산의 정밀도를 위해 ExponentialNoError.sol 라이브러리를 사용한다.

많이들 아는 것과 같이, Solidity에서는 소수점 계산이 부동소수점 방식이 아닌 고정소수점 연산을 이용한다(아무래도 소수계산 자체가 존재하지 않으니..). 따라서 정밀하거나 중요한 연산을 수행할 때 계산 로직이 왜곡될 수 있다는 위험이 존재한다.

아래 예시와 같이 나눗셈 → 곱셈의 순서로 연산을 하거나, 혹은 그렇게 했더라도 나눠지는 수의 스케일과 나누는 수의 스케일이 비슷할 경우 계산 결과의 왜곡이 생길 수 있다.

1200 * 5 / 1000 = 6
1200 / 1000 * 5 = 5

 

따라서, 이러한 위험을 방지하기 위해, 대부분 연산에서 Mantissa를 이용해 1e18을 곱한 수로 연산을 수행한다.

(Mantissa이외에 Double(1e36)이라는 개념도 사용된다.)

cToken, Underlying Token의 Decimals

모든 ERC20, Native Token의 Decimal은 18로 통일되어있는 것이 아니라 모두 다른 decimal을 이용하고 있다. 예를들어 USDC의 decimal은 6이고, WBTC는 8이다. 그리고 ETH는 18이다.

이처럼 모든 토큰별로 매핑되는 각 cToken의 가치와 관련된 계산을 할 때, 모든 Underlying Token의 decimal을 고려하기는 다소 불편한 감이 있다. 따라서 compound에서는 모든 cToken의 decimal을 아래와 같이 6으로 고정해 이용한다.

사진출처: https://docs.compound.finance/v2/#networks

하지만, 이럴 경우 underlying token과 cToken간 decimal 차이가 발생하여 계산과정에서 불편함이 생길 수 있다. compound에서는 이를 다음과 같은 방법으로 계산하여 해소한다.

exchangeRate = (getCash() + totalBorrows() - totalReserves()) / totalSupply()
// getCash: underlying.balanceOf(address(this))
// totalBorrows: User들이 borrow한 총량
// totalReserves: 준비금 총량
// totalSupply: cToken 총 발행량
underlyingTokens = cTokens * exchangeRate / 10^(18+underlyingDecimals-cTokenDecimals)

Calculate Accrual Interest

실제 은행에서도 그렇지만, web3 Defi에서도 이자율이자는 정말 중요하다. 사용자들이 납부해야 할 이자와, 예치자들에게 돌려줄 수익이 보장되고, 정확히 계산되어야 시장이 건전하게 돌아갈 수 있으니 말이다.

그렇다면, 이러한 이자는 어떻게 업데이트해주는걸까? web3에서는 모든 계산이 user가 호출하는 로직에서 수행되고, 매 호출마다 supply, borrow를 한 모든 user의 이자가 업데이트된다면 user들은 프로토콜을 이용하려하지 않을 것이다. (gas fee감당불가) 이를 해결하기 위해, compound에서는 index라는 개념을 통해 이자를 누적시킨다. index가 어떻게 사용되는지, 그리고 어떻게 계산되는지를 알아보자.

계산 로직

function accrueInterset() {
	...
	borrowRate = interestRateModel.getBorrowRate(cash, borrows, reserves)
	...
	simpleInterestFactor = borrowRate * blockDelta // 마지막 업데이트 이후 지난 블록 수 
	borrowIndex = borrowIndex * (simpleInterestFactor + 1)
	...
}

baseRatePerYear: The approximate target base APR, as a mantissa (scaled by BASE) : 연이자율

multiplierPerYear: The rate of increase in interest rate wrt utilization (scaled by BASE) : Utilization 가중치

function getBorrowRate(cash, borrows, reserves) returns (borrowRate){
	ur = borrows * 1e18 / (cash + borrows - reserves)
	borrowRate = ur * multiplierPerBlock / BASE + baseRatePerBlock
}

말로 풀어보자.

우선, borrowRate는 baseRate에 utilizationRate에 multiplier를 곱한 값을 더한 결과이다.

utilizationRate(ur)는 활용률 즉, 프로토콜에 예치된 자산 중 대출에 활용된 자산의 비율을 말하며, 활용이 많이 되고 있다는 것은 해당 자산이 많이 사용된다는 말이므로, 이에 대한 borrowRate가 커지게 된다.

이를 통해 도출된 borrowRate와 마지막 업데이트 이후 지난 블록 수(blockDelta)를 곱해 simpleInterestFactor를 계산하고, 현재 borrowIndex에 이 값을 곱한 후 borrowIndex에 더하여 업데이트한다.

이러한 방식으로 borrowIndex는 처음 세팅 이후, 예치된 자산의 활용율에 따라 점점 커지는 방식이라고 생각할 수 있다.

활용 로직

function borrowBalanceStoredInternal() returns (borrowBalance) {
  ...
  borrowSnapshot = accountBorrows[account]
  borrowBalance = borrowSnapshot.principal * borrowIndex / borrowSnapshot.interestIndex
  ...
}

user가 borrow한 token의 이자를 포함한 총량을 계산하는 과정에서 사용된다.

borrow 원금에 현재 borrowIndex를 곱한 후, borrow했을 때의 index(애매한데, 일단 그렇다 칩시다)로 나눠주면 borrow한 총량을 나타낼 수 있다.

이처럼, 매 호출마다 모든 유저의 이자를 업데이트해주는 것이 아닌, index값만 업데이트해주고, borrow한 총량을 알고싶을 때 index값과, borrow할 때의 index값만 알면 borrowBalance를 알 수 있으니 간단하게 최적화 문제를 해결할 수 있다.

 

ref

https://docs.compound.finance/v2

 

Compound v2 Documentation

Getting Started Introduction to Compound v2 The Compound protocol is based on the Compound Whitepaper (2019); the codebase is open-source, and maintained by the community. The app.compound.finance interface is open-source, and maintained by the community.

docs.compound.finance

https://deephigh.gitbook.io/

 

Welcome | DeepHigh

Welcome

deephigh.gitbook.io

반응형
반응형

 

4개월간의 업사이드아카데미 1기 DeepHigh팀 팀장으로서의 활동을 끝내고, 어떤 공부를 해야할지를 고민해보다가 프로젝트 내용에 대해 내가 정말 완벽히 이해하고있는가를 생각해보았다.

작업했던 모든 산출물을 완벽하게 이해하고있는지에 대해 우선적으로 생각해 보았고, 더 공부하고 발전시킬 부분이 있다고 생각하였다.

프로젝트의 주제는 Defi Lending Protocol Audit이었으며, 이에 대한 산출물은 EVM기반 Lending 프로토콜에 대한 위협모델링 및 관련 자료들이다. 구체적인 내용은 아래 내용을 통해 알 수 있다. 

https://deephigh.gitbook.io/deephigh

 

Welcome | DeepHigh

Welcome

deephigh.gitbook.io

실제 위협모델링을 통해 Lending 프로토콜상의 취약점을 찾아보고자 했지만, 정말 사소한 몇 가지 말고는 찾지 못한 것이 다소 아쉽다.

업사이드아카데미 활동에 대한 내용은 추후 시간이 된다면 작성해보고싶다.


내가 생각하는, 프로젝트 기간동안(교육기간 제외) 공부 및 산출한 결과는 다음과 같다.

 

공부 (docs, code 포함)

  1. EVM-based Lending 생태계에 대한 전반적인 이해
  2. Compound v2, Venus protocol에 대한 90% 이해
  3. Euler v2에 대한 70%정도의 이해
  4. BIFI (Bifrost Lending), BendDAO v2에 대한 30~50% 이해

산출물

  1. Defi Lending protocol threat modeling framework 제시 및 threat modeling
  2. Lending Invariant Checklist 제시
  3. 위협모델링, Checklist에 대한 Validation

이처럼, Lending Protocol에 대해, 그리고 다소 정책(?)에 대한 내용인 위협모델링이라는 것에 대해 공부해보면서, Defi에 대한 정말 전체적인 공부를 해보고싶은 생각이 들었다.


본인은 이제 3개월정도 뒤면 병역의무를 수행하러 군대에 가야하는데, 그 전까지 최대한 많은 공부를 해보고싶다는 생각이 들었다. 그래서 내가 어떠한 것을 더 배워야할지 정리해보았다.

  1. 공부한 내용 기록 남기기
    1. Compound v2는 무조건 남길 것 같다. (마지막에 별첨 느낌으로 Venus도 다루지 않을까?)
    2. Euler v2도 남길 것 같다. (compound v2와 구조적으로 다르면서 독특한 특징이 여럿 있어 꽤나 흥미로운 녀석이다.)
  2. 공부할 내용 남기기
    1. Aave v3 (TVL 1위인 프로토콜은 보는게 맞지…)
    2. Leverage Lending, NFT 담보대출 등 Lending의 특수기능을 구현한 프로토콜에 대해 공부할 것 같다.

본 스터디에 대한 나의 목적은, 공부한 프로토콜에 대해 누군가 질문을 했을 때, 해당 질문에 대한 정확한 설명, 그리고 어떻게 구현되어있는지까지 모든 것을 답할 수 있을 정도로 공부를 해보고자 한다.

 

추가하고 싶은 내용, 추가하지 않아도 될 것 같은 내용 등이 있으면 언제든 이 글을 수정해보도록 해야겠다.

많은 사람들, 특히 한국인들이 내 포스팅을 통해 Lending protocol에 대한 흥미를 키울 수 있다면 좋을 것 같다.

 

대략적인(예상되는) 앞으로의 포스팅 순서

1. Compound v2: 아무래도 나는 보안공부를 하는사람이니... Compound v2 fork에서 발생했던 사고사례분석도 더해볼까 한다.

2. Aave v3

3. Euler v2

4. BendDAO (이건 안할수도 있을 것 같다.)

반응형

+ Recent posts