[큹융공학 series] 3. 리본파이낸스 코드 분석

최주원
Decipher Media |디사이퍼 미디어
17 min readAug 27, 2022

서울대학교 블록체인 학회 디사이퍼(Decipher) 큹융공학 팀에서 크립토 옵션 시장 현황 및 분석에 대한 글을 시리즈로 연재합니다. 본 글은 ‘큹융공학: 크립토 옵션 시장 현황 및 분석’ 시리즈의 첫 번째 편으로, 본 편에서는 크립토 옵션 시장에 대해 전반적으로 분석하였습니다. 대표적인 옵션 프로토콜인 리본 파이낸스에 대한 로직 분석은 2편(링크), 리본 파이낸스 코드 분석은 3편(링크), 옵션 프로토콜의 한계 및 개선점은 4편(링크)에 서술하였습니다

Authors

최주원
Seoul Nat’l Univ. Blockchain Academy Decipher(@decipher-media)
Reviewed by 정수현, 정재환

[목차]

  1. 리본 파이낸스 구성요소
  2. 유저스토리와 액션별 분석
  3. 마치며

1. 리본 파이낸스 구성요소

리본 파이낸스는 크게 다음의 컴포넌트(Component)들로 구성됩니다.

  • 세타볼트(ThetaVault)
  • 스트라이크 셀렉션(StrikeSelection)
  • 오핀(Opyn)
  • 지노시스 옥션(Gnosis Auction)

각 컴포넌트의 역할을 간략히 살펴보고, 이를 바탕으로 리본 파이낸스가 어떻게 동작하는지를 이해해보도록 하겠습니다.

1.1. 세타볼트(ThetaVault)

세타볼트는 리본 파이낸스의 핵심 기능인 자동 옵션 투자 전략을 실행하는 컴포넌트입니다. 디파이에서의 옵션 투자는 유저가 예치한 자산을 바탕으로 특정 옵션 전략을 이행하는 방식으로 진행됩니다. 세타볼트는 자동화된 방식의 옵션 투자 전략을 제공하여, 유저가 반복적으로 트랜잭션을 실행하지 않더라도(가스비를 반복적으로 소비하지 않더라도) 수천 명의 유저의 트랜잭션을 처리할 수 있습니다. 이러한 자동화된 방식을 통해 유저는 세타볼트에 자산을 세트(set)하고, 잊고(forget) 있어도 이자를 지급받을 수 있습니다.

1.2. 스트라이크 셀렉션(StrikeSelection)

스트라이크 셀렉션은 용어 뜻 그대로 옵션의 스트라이크 프라이스, 즉 행사 가격을 결정합니다. 앞서 글에서 소개한 것처럼 리본 파이낸스 V1에서는 자동화되지 않은, 매니저를 통해서 행사가격을 결정하는 방식을 취했지만, V2에서는 탈중앙화된 스트라이크 셀렉션을 통해서 결정하는 방식으로 업데이트되었습니다. 기존 V1에서는 세타볼트가 옵션을 판매할 행사 가격을 리본 파이낸스 매니저들의 논의를 통해서 결정하였습니다. 이러한 방식은 행사가격을 결정할 때마다 매번 매니저들이 개입해야 하고, 탈중앙성과 확장성이 떨어질 수밖에 없다는 한계를 지닙니다. V2에서 변경된 스트라이크 셀렉션은 행사가격을 결정하는 데에 있어 매니저의 개입을 제거하여 탈중앙성과 확장성을 확보하였습니다.

1.3. 오핀(Opyn)

리본 파이낸스는 오핀 V2의 감마 프로토콜(Gamma Protocol)을 활용하여 옵션을 발행하고, 누구나 거래할 수 있는 ERC20 형태의 자산으로 만듭니다. 오핀은 탈중앙화된 방식으로 누구나 옵션을 만들 수 있고, 거래할 수 있도록 컨트랙을 제공하는 옵션 플랫폼이라고 생각하시면 됩니다.

1.4. 지노시스 옥션(Gnosis Auction)

지노시스 옥션은 경매를 진행할 수 있는 플랫폼을 제공하는 프로토콜입니다. 리본 파이낸스는 오핀 프로토콜을 통해서 만든 ERC20 형태의 옵션을 지노시스 옥션을 통해서 거래합니다.

2. 유저스토리와 액션별 코드 분석

앞서 리본 파이낸스의 컴포넌트들을 살펴보았다면, 실제 유저가 리본 파이낸스를 사용하게 되는 유저 스토리에 기반하여 어떠한 코드로 구성이 되는지를 분석해보도록 하겠습니다.

2.1. 컨트랙별 역할

리본 파이낸스는 크게 Ribbon Finance(옵션)와 커브의 ve 모델을 하드포크한 Ribbon DAO로 구성되어있는데, 본 글에서는 거버넌스 영역은 다루지 않고 옵션과 관련된 비즈니스 로직만을 다룹니다.

Vault.sol

  • 모든 Vault type에 공용으로 쓰이는 자료구조를 담고 있는 컨트랙

VaultLifecycle.sol

  • 주 단위의 볼트 로직들에 대한 처리를 담당하는 컨트랙(시간과 관련된)

RibbonVault.sol

  • RibbonThetaVault와 RibbonDeltaVault 사이에 공유되는 로직들을 포함하고 있는 컨트랙

RibbonThetaVault.sol

  • 주 단위로 오핀을 활용한 숏 옵션 포지션을 만드는 컨트랙

StrikeSelection.sol

  • 전달받은 파라미터를 바탕으로 적절한 스트라이크 프라이스를 계산하는 컨트랙

2.2. 유저 스토리

아래의 유저 스토리를 기반으로 코드에서 실제로 어떻게 동작하는지를 확인해보도록 하겠습니다.

  1. 유저가 100ETH를 세타볼트(ThetaVault)에 예치한다.
  2. 금요일 10 am UTC에 세타볼트의 자산을 바탕으로 oToken(옵션을 의미하는 오핀 프로토콜의 ERC20 기반의 자산)을 민트한다. 이때, 유저가 예치한 100ETH는 오핀 프로토콜에 일주일간 락업 된다.
  3. 세타볼트는 100 oToken을 획득하고 나서 옥션에 넣는다.
  • 누구나 옥션에 참여하여 oToken에 대한 비드(bid)를 넣을 수 있다. → 옵션의 프리미엄을 ETH로 지급한다.
  • 옥션이 만료되면, 세타볼트는 프리미엄 별로 1ETH를 수집한다.
  • 팔리지 않은 옵션은 소각되며, 오핀 프로토콜에서 한 개의 oToken을 인출하면 1단위의 담보자산을 수령할 수 있다.

2.3. 컨트랙 상태 (Vault.sol)

먼저 전역적으로 사용되는 컨트랙 상태(state) 중 과정을 이해하기 위해서 필수적인 상태들에 대해 간략히 살펴보도록 하겠습니다.

다음은 하나의 볼트를 만드는데 필요한 VaultParams와 만들어진 볼트가 가지고 있는 상태를 관리하는 VaultState 구조체입니다.

2.3.1. VaultParams

VaultParams 는 다음의 정보들을 관리합니다.

  • isPut : 판매하는 옵션의 타입 (true 라면 풋옵션, false 라면 콜옵션)
  • decimals: 볼트의 decimal 단위
  • asset: 볼트에서 사용하는 자산 종류
  • underlying: 볼트에서 판매한 실제 자산 (ex : ETH 콜옵션이라면 ETH가 underlyingAsset)
  • minimumSupply: 볼트가 최소한으로 관리해야 하는 공급량
  • cap: 볼트 캡

2.3.2. VaultState

  • round : 몇 주차인지를 의미하는 정보
  • lockedAmount: 판매하는 옵션을 위해서 락업 된 수량
  • lastLockedAmount: 이전 회차에서 판매되기 위해서 락업 되었던 수량 → 퍼포먼스 수수료를 계산하기 위해서 활용됨
  • totalPending: 현재 얼마만큼의 자산이 존재하는지
  • queuedWithdrawShares: 이전 회차에서 인출하기 위해서 큐 된 수량

2.3.3. DepositReceipt

  • round: 몇 주차인지를 의미하는 정보
  • amount: 예치한 수량을 의미하는 정보
  • unredeemedShares: 예치 후 인출되지 않은 지분

2.3.4. Withdrawal

  • round: 몇 주차인지를 의미하는 정보
  • shares: 인출하고자 하는 지분

2.4. 예치

다음으로, 리본 파이낸스를 통해 옵션을 거래하기 위해서 가장 먼저 진행해야 하는 예치 과정에 대해서 알아보도록 하겠습니다.

2.4.1. depositdepositETH

위 함수를 통해서 자산을 예치하게 되는데 리본 파이낸스는 유저 편의성을 위해서 ETH를 받으면 내부적으로 랩핑(Wrapping)된 WETH로 변환하여 처리하기 위해서 ETH와 ERC20 자산을 구분한 depositETHdeposit, 두 개의 함수를 통해 예치를 진행합니다.

두 함수는 msg.value를 사용하는 것, WETH로의 변환 과정을 거치는 것 외에는 동일하게 내부적으로 _depositFor 함수를 호출하게 됩니다.

2.4.2. _depositFor

이 함수의 역할은 예치한 유저를 대상으로 볼트 쉐어(Vault Share)를 발행해주는 것입니다. 볼트 쉐어는 2.3.3에서 살펴보았던 DepositReceipt로 구성됩니다.

인자로는 수량과 볼트 쉐어의 주인이 될 어카운트(creditor로 표현된)를 받습니다. 가장 먼저 현재 이 함수가 실행되는 라운드가 언제인지를 전역적인 볼트의 상태를 관리하는 VaultState를 통해서 가져옵니다. 만약 이번 라운드에 최대치로 정해둔 캡(Cap)보다 지금 들어온 수량을 더한 총 수량(totalWithDepositedAmount)이 크다면 함수를 리버트(revert) 시킵니다. 또한, 총 수량이 미니멈 서플라이(minimumSupply)를 도달하지 못하더라도 리버트 시킵니다. 해당 유저의 수량을 가지고 볼트 쉐어를 기록하기 전에 Deposit 이벤트를 에밋(emit)합니다.

이벤트를 방출한 후에 위에서 언급한 DepositReceipt를 기록하기 위해서 수량(depositAmount)과 인출되지 않은 지분(unredeemedShare)을 계산하는 과정을 거칩니다. 이번 라운드와 지분당 가격, 소수점 표기를 위한 decimal을 가지고 getSharesFromReceipt 함수를 호출하여 인출되지 않은 지분을 계산합니다. 다음으로 이번 라운드의 정보에 유저가 예치한 적이 있다면 가중되어서 계산하기 위한 예외 처리를 진행한 후에 depositAmount까지 계산합니다.

마침내 이렇게 계산된 값들을 통해서 depositReceipt을 만듭니다. 그리고 끝에서 이번 예치 과정에서 추가된 수량만큼을 볼트의 전역 상태인 볼트스테이트(VaultState)의 totalPending에 기록하여 한 라운드가 진행되는 중에 예치된 자산을 펜딩(Pending) 상태로 관리합니다. 펜딩 상태로 관리되고 있는 자산은 나중에 리본 파이낸스의 관리자(Manager)가 실행하게되는 rollToNextOption이라는 함수를 통해서 새로운 포지션을 만들게 됩니다.

2.5. 행사가격 결정 및 옵션발행

예치 과정이 종료되면, 컨트랙상 자산은 펜딩 상태로 관리가 되고 있고, 실제로 이 자산들을 이용하여서 옵션을 발행해야 합니다. 옵션을 발행하기 위해서는 옵션의 행사 가격(StrikePrice)이 필요한데, 이 과정을 해결해주는 것이 리본파이낸스의 알고르드믹 스트라이크 셀렉션(StrikeSelection)입니다. 스트라이크 셀렉션을 통해서 행사가격을 계산한 후에 오핀 프로토콜을 활용하여 oToken을 발행하는 것으로 옵션 발행의 과정이 진행됩니다. 이러한 옵션 발행 과정의 전반을 처리하는 함수는 commitAndClose라는 함수입니다.

세타볼트의 commitAndClose 함수는 위와 같이 VaultLifecycle에 있는 CloseParams의 형태로 데이터를 가공하여, VaultLifecyclecommitAndClose를 호출하는 식으로 구현되어 있습니다.

VaultLifecyclecommitAndClose를 살펴보도록 하겠습니다.

commitAndClose 함수의 역할은 위에서 설명드렸듯이 행사가격을 결정하고, 옵션을 발행하는 것입니다. 먼저, 옵션을 발행하기 위해서 필요한 만기일, 행사가격, 풋 옵션 여부, 자산 종류, 세타볼트에서 사용되는 자산의 종류 등의 정보를 가공합니다. 여기서 행사가격을 계산할 때는 StrikeSelection을 활용하여서 계산을 하는 것을 확인할 수 있습니다. 행사가격은 이렇게 델타(delta) 값을 제외하고는 컨트랙상 자동화된 알고리즘을 통해서 계산됩니다.

이렇게 가공된 데이터를 통해서 오핀 프로토콜의 oToken을 배포하게 됩니다. 배포하는 과정은 내부적으로 작성해둔 getOrDeployOtoken을 통해서 진행하게 되는데, 이 함수는 내부적으로 이미 배포되어 있는 oToken이라면 새로 배포 과정을 거치지 않고, 배포되어 있는 토큰을 가져와서 리턴(return)하는 예외처리가 아래처럼 되어있습니다.

commitAndClose 과정을 통해서 예치 과정에서 유저가 예치한 펜딩 상태의 자산을 기반으로 옵션 발행이 종료되고 나면, 위에서 언급했던 리본 파이낸스의 매니저가 rollToNextOption을 호출함으로써 다음 라운드를 진행하게 됩니다. 이 때 볼트의 지분도 민트 됩니다. rollToNextOption 과정이 진행되어야만 유저들은 볼트의 지분을 확인할 수 있습니다.

2.6. 옵션 거래

위 과정을 통해서 발행된 옵션을 거래하기 위해서 리본 파이낸스는 지노시스 옥션을 활용합니다. 이 과정은 일반적인 유저라면 지노시스 옥션 사이트를 통해서도 진행 가능한 과정을 리본 파이낸스에서는 함수를 구현해두어 자동화했습니다.

먼저 getAuctionSettlementPrice 함수를 통해서 등록된 옥션의 등록된 가격을 가져옵니다. 만약 옥션이 아직 등록되지 않았다면 이 함수는 내부적으로 0 을 리턴하여, 옵션 판매가 진행되지 않도록 합니다. 그리고 sellToBuyers 함수를 실행하게 되는데, 이 함수는 큐에 등록된 여러 구매자들에게 옵션을 판매하게 됩니다. 단, 이 함수는 실제로 옵션을 판매하게 되는만큼 누구나 실행할 수 없고, 볼트에서만 판매할 수 있게끔 구현되어 있습니다. 볼트에서만 판매할 수 있기 때문에 볼트에서는 적절한 가격에 옵션을 판매하고, 프리미엄을 수령하여 높은 수익률을 올릴 수 있습니다. 그렇다면 위의 sellOptionsToQueue 함수가 누구나 호출할 수 있는 형태로 구현되어 있어 의문을 살 수도 있는데 실제로 이 함수를 사용하는 세타볼트에서는 아래와 같이 구현되어 있고, 위의 함수를 라이브러리처럼 사용하는 방식으로 구현되어 있어 리본파이낸스의 매니저(코드 상에서는 Keeper로 표현)만 위 함수를 통해서 옵션을 판매할 수 있습니다.

2.6. 인출, 옵션 만기 이후

리본 파이낸스에서의 인출 과정은 크게 스탠다드 인출(Standard Withdraw)과 인스턴트 인출(Instant Withdraw)으로 나뉘어서 진행됩니다.

스탠다드 인출 과정은 표현 그대로 일반적으로 라운드가 끝난 후 금요일 10 am UTC 이후에 인출할 수 있는 과정입니다. 반면에 인스턴트 인출 과정은 예치한 자산의 중간인 라운드가 진행 중인 때에 즉 미드 위크(mid-week) 때 인출이 가능합니다.

2.6.1. 스탠다드 인출 과정(Standard Withrdraw)

스탠다드 인출 과정은 다음과 같은 순서로 진행됩니다.

  1. initiateWithdraw 함수를 통해서 인출해야하는 Withdrawal 객체를 큐에 올립니다.
  2. 이때 initiateWithrdraw 함수는 라운드 진행 중에 호출 할 수 있습니다.
  3. 이 함수는 큐인 만큼 여러 번 호출할 경우 하나의 큐에 쌓이고, 나중에 completeWithdraw 호출 시 중첩되어 적용됩니다.
  4. 이후 completeWithdraw 함수를 통해서 마침내 자산을 인출하고, 지분을 소각합니다.
  5. 이때 completeWithdraw 함수는 금요일 10 am UTC에만 호출할 수 있습니다.

initiateWithdraw 함수부터 살펴보겠습니다.

세타볼트의 initiateWithdraw 함수는 내부적으로 _initiateWithdraw 함수를 호출하고, 인출하려는 지분을 큐에 추가합니다.

그리고 인출할 자산이 0인지 0이 아닌지를 체크하는 과정을 거치고, 이번 라운드가 몇 번째 라운드인지에 관한 정보를 가져옵니다. 다음으로 실제 인출에 사용될 Withdrawal 객체를 만드는 과정을 거치게 됩니다. 데이터를 실제로 채워넣기 전 인출을 호출한 자, 인출하려는 지분 수, 현재 라운드 정보를 InitiateWithdraw 이벤트로 방출합니다.

다음으로 이미 인출하려는 지분이 존재하고, 같은 라운드라면 더해서 계산이 되어야하기 때문에 같은 라운드인지를 조사하게 되고, 만약 같은 라운드가 아니라면 인출하려는 지분이 사전에 존재하지 않았는지를 체크하고 라운드 정보를 업데이트합니다. 그리고 마침내 인출하려는 지분을 업데이트하고, 이를 유저에게 전송합니다.

금요일 10 am UTC가 지나고, 한 라운드가 종료되고 나면 유저는 아래의 completeWithdraw 함수를 호출할 수 있습니다.

이 함수는 내부적으로 _completeWithdraw 함수를 호출하고, 이전 라운드의 인출 수량을 업데이트합니다.

_completeWithdraw 함수에서는 이전 initiateWithdraw에서 활용했던 객체를 다시 가져옵니다. 여기서의 지분과 라운드를 업데이트 해야하므로 지역 변수에 이를 담아두고, 몇 가지 조건을 검사합니다. 먼저 인출하려는 지분이 0보다 큰지를 체크하여 initiateWithdraw 과정을 올바르게 거쳤는지를 체크합니다. 다음으로 현재 시점에서 라운드가 지난 라운드인지를 체크하여 끝난 라운드의 Withdrawal만이 실행될 수 있게 합니다.

조건들을 검사한 후에 유저의 Withrdrawal 객체를 초기화하는 과정을 거치고, 전역 변수에서도 인출하려는 만큼을 감소시킵니다.

그리고 인출할 지분, 지분당 가격, 데시멀을 통해서 실제로 인출할 자산의 수량을 계산합니다. 계산이 끝났으므로 ERC20 형태인 인출할 지분은 소각하고, 마지막으로 유저가 인출할 수 있는 수량을 유저에게 전송하며 함수가 끝납니다.

2.6.2. 인스턴트 인출 과정(Instant Withdraw)

인스턴트 인출 과정은 스탠다드 인출 과정과는 달리 라운드가 종료되지 않은 주의 중간에만 실행할 수 있습니다. 예를 들어서 수요일에 A자산을 예치한 사람은 라운드가 종료되는 금요일 10 am UTC 전인 수요일부터 금요일 사이에만 인스턴트 인출 과정을 활용할 수 있습니다.

인스턴트 인출 과정은 위의 withdrawInstantly 함수로 진행되는데, 스탠다드 인출 과정보다 훨씬 간단한 절차를 가집니다.

예치 시 만들었던 depositReceipt를 가져오고, 함수를 호출하는 시점의 라운드가 예치했을 때의 라운드와 일치하는지를 검사합니다. 이를 통해서 주중에만 실행할 수 있게끔 합니다. 다음으로, 예치한 자산보다 더 많은 자산을 인출하는 것을 방지하기 위한 체크를 진행합니다. 기본적인 체크가 끝난 후에 예치한 자산에서 인출하려는 자산을 감소시켜 업데이트합니다. 다음으로 예치 시 펜딩 상태로 관리하던 전역 변수에서도 인출하려하는 자산의 수량만큼을 감소시킵니다. 끝으로 누가 인출했는지, 얼마를 인출했는지, 라운드는 언제인지를 InstantWithdraw 이벤트로 묶어 방출하고, 인출할 수량을 유저에게 전송하며 함수를 종료합니다.

마치며

본 글에서는 리본 파이낸스를 구성하고 있는 요소인 세타볼트, 스트라이크 셀렉션, 오핀, 지노시스 옥션에 대해서 알아보고, 자산을 예치하는 과정, 옵션을 발행하는 과정, 옵션을 거래하는 과정, 옵션을 인출하는 과정에 대해서 코드레벨에서 분석을 해보았습니다. 다음 글에서는 이러한 옵션 디파이 프로토콜의 한계 및 개선점에 대한 분석을 통해서 옵션 디파이 프로토콜이 나아가야할 방향에 대한 고민을 공유하도록 하겠습니다.

--

--