Olympus V2 Bonds Deep Dive

Olympus V2 Bonds Deep Dive

Protocol Overview and Developer Walk-through

·

36 min read

I'm trying a new format where I'll provide a deep-dive walk-through of popular DeFi protocols from the perspective of a protocol designer and developer. In this post, I'm going to be looking at OlympusDAO's Bonds. If you're familiar with how the Olympus V1 contracts worked, I've highlighted some differences at the bottom of the post.

Background

OlympusDAO and its forks proliferated this past year with high nominal rates of return from staking and the popular (3,3) meme. OlympusDAO created and popularized two main concepts for DeFi platforms:

  • Bonding to achieve Protocol Owned Liquidity (POL) or other Protocol Reserve objectives. Bonding is an improvement over Incentivized Liquidity Farming because it is a one-time expense and you then own the liquidity vs. renting it perpetually from liquidity providers.
  • Staking system designed using game theory to achieve a Nash equilibrium where all participants best strategy is to stake. "(3, 3)"

My goal is not to teach a bunch of developers how to fork Olympus so we have more proliferate. However, understanding major innovations is important to understand the ecosystem and build on top of it/continue innovating.

In this post, I'm going to focus on the Bonds framework, which I think is the biggest innovation that OlympusDAO has contributed to the ecosystem. The V2 BondDepository contract and associated types were published in January 2022 and are written by Zeus and Indigo for OlympusDAO. The code and contracts reviewed in this post is as of commit 0646cc499d7af8a135f9bf6c653e2c62299f52a1 on the OlympusDAO/olympus-contracts GitHub repository.

Concepts

To start with, it's helpful to understand some the key concepts of OlympusDAO and Bonds.

DAO and Protocol

  • OlympusDAO - The decentralized autonomous organization (DAO) that develops, deploys, and operates the Olympus decentralized reserve currency protocol. The protocol has two main user-facing components: Bonding and Staking. Bonds provide assets for the Treasury in exchange for the native protocol token. The market price of OHM is used in bonding, which has historically always been above the backed value (1 DAI). When the protocol makes a profit by bonding assets with OHM, it distributes the profits to users staking OHM as new OHM tokens minted with the excess collateral received. In the future, other income sources could be used to mint new rewards for stakers.
  • Olympus V1 - The original version of the protocol that was released in March 2021. While upgrades were made to specific contracts over the course of 2021, the base concepts remained the same.
  • Olympus V2 - A re-written version of the protocol that was deployed between December 2021 and January 2022. The changeover required a migration of treasury assets, deployment of new Bond Depository contracts, and migration of user tokens.

Tokens

  • OHM - The base ERC20 token of the protocol. Each OHM is backed by 1 DAI by the DAO.
  • sOHM - "Staked OHM". A rebasing ERC20 token issued by the protocol in exchange for users staking their OHM. Rebasing means that the number of tokens a user holds changes over time as the protocol "rebases" the token to issue staking rewards. Primary reason for this is to have gas-less staking rewards.
  • gOHM - "Governance OHM". A wrapped version of the sOHM token that does not rebase. Instead, it becomes worth more sOHM overtime as the staking index increases. It is now used for governance of the protocol as of the V2 migration.

Bond Terminology

  • Bond Depository - A smart contract that lets users bond (deposit) specified Reserve or LP assets to the protocol in exchange for a Payout in OHM after a Vesting Period (the payout is automatically staked as sOHM or gOHM in V2 for the user during the vesting). A Bond Depository contract can only have one payout token.
  • Treasury - A smart contract that holds the assets users bond (deposit) to the protocol.
  • Base Token - The token used to payout users for their bond. In the Olympus Protocol, this is normally OHM, but, in theory, it could be any token that a protocol wants to exchange for bonded assets. An examples of non-OHM base token contracts are the Olympus Pro bond offerings that they provide for other protocols as a service.
  • Quote Token - The token being bonded by a user and deposited in the Treasury.
  • Market - A specific Bond offering on a Bond Depository contract. Each Market is for a specific asset to bond, has its own price that fluctuates with deposit activity and tuning adjustments, has a set expiration date/time and vesting period, and has a specific capacity that it can pay in OHM or accept in quote tokens.
  • Note - A data structure with the terms agreed to as a result of the user bonding tokens on an active Market. You can think of it as an IOU from the Bond Depository to a user with a specific time that the user can retrieve their Payout.
  • Payout - The amount a Note holder will receive in gOHM at the end of the Note's Vesting Duration.
  • Front-end Operator - A person or entity that operates a front-end interface for the Olympus protocol. The Bond Depository V2 contract includes an incentive mechanism to allow the team to decentralize the creation and operation of interfaces. A Front-end Operator can be whitelisted by the protocol and receive rewards for user deposits that they route to the protocol. Not shown on the diagram.
  • Staking Contract - A smart contract that users can stake their OHM with and receive sOHM or gOHM. The staking contract issues the staking rewards to users after each protocol epoch (specified time period) by rebasing sOHM.

Smart Contracts and Architecture

The OlympusDAO protocol contains several smart contracts and governance roles that work in tandem to provide the protocols services. To keep things digestible, I'm only going to focus on the Bonding framework in this article. Here is a diagram that shows the different contracts and the lifecycle of a Bond Market with a user bonding assets to receive a payout.

Olympus V2 Bonds - Dark

We will look at the code of each of these contracts in detail later in the article.

Bonds in Practice

Before we dive into the code, it's important to understand the way Bonds are used in practice by OlympusDAO, the different types of Bonds and parameter options available to them, and how Bond Pricing works on the protocol. Once you understand the concepts that the contracts are implementing, it is easier to see why the code is structured as written.

Bond Market Parameters

A key challenge for the Olympus Policy Team is determining the right settings to achieve the DAO Policy objectives in current and upcoming market conditions. Since they are trying to build a decentralized reserve currency, OlympusDAO is trying to build a diverse, deep treasury with stable assets (such as DAI and FRAX) and needs deep liquidity for their token to enable a high volume of transactions (e.g. as OHM/DAI or OHM/FRAX LP tokens). The following parameters are set whenever a new Bond Market is created.

  • Quote Token - The token that the user can deposit for a bond on this Market. This can be a Reserve asset or LP token that the DAO wants to acquire in the Treasury.
  • Capacity - Each Market has a capacity for the amount of collateral it can receive or the amount of OHM that can be paid out. The creator can specify which type of capacity to use. The Market will stop accepting new bonds when the capacity is reached.
  • Debt Buffer - A percentage over the initial target debt (Capacity in Base Token) that the Total Market Debt is allowed to exceed. This acts as a circuit breaker and closes the Market immediately if hit. The purpose is to save the protocol in the event of a Quote Token having rapid decline (e.g. a stable coin losing its peg). It's important to set the Debt Buffer with enough room for normal market activities to be able to hit the target capacity of the Market.
  • Initial Price - The starting price of the Market in the number of Quote Tokens per Base Token
  • Expiration - A timestamp when the Market will stop accepting new bonds, regardless of whether the capacity has been reached or not.
  • Vesting Period - Amount of time that users must wait between bonding assets and redeeming their payout. Vesting can be set as a Fixed Term (e.g. 5 days) or a Fixed Expiration (all bonds vest at a specific timestamp).
  • Target Deposit Interval - Target frequency for users to bond assets on the Market. It determines the Max Payout for any one deposit: \(Max Payout = Capacity * Target Deposit Interval / Total Duration\). Additionally, it is used by the _tune function to make price adjustments.
  • Tuning Interval - Frequency of how often the Market auto-adjusts the Bond Price and the amount of time that downward Bond Price adjustments will be phased in over. We'll discuss Tuning in more detail in the Bond Pricing section.

Creating a Bond Market

The Olympus Policy Team creates Bonds with different parameters to achieve their goals. Bonds are announced on the Olympus Discord the parameters specified, such as "DAI bonds have a capacity of 8,260 OHM over 7 days which is approximately $300,000 per day with a deposit target of every 6 hours. The bonds will have a 14-day vesting term, are automatically staked, and can be claimed at the end of the vesting term." This translates to the following Market parameters on the contract:

  • Quote Token: DAI
  • Capacity: 8,260 OHM (Capacity is in the Base Token)
  • Expiration: 7 days
  • Vesting Period: 14 days (Vesting Type is Fixed Term)
  • Target Deposit Interval: 6 hours

All time values are tracked and input as seconds.

The following parameters are not specified in the statement.

  • Initial Price: Not specified, but it is implied by the dollar amount per day compared to the OHM to be paid out. \(7 * 300,000 \ DAI / 8,260 \ OHM = ~254 \ DAI/OHM\)

    A key question here is "Why did they pick that initial price?". To make the bond attractive to users, the payout needs to provide a positive ROI from the amount deposited. The initial price is typically set at a discount to the current price of OHM on the market to create this ROI. For example, if OHM was trading at $267 when the bond was created and the initial price is $254, then the ROI for the user would be ~5% over the vesting at the time of bonding. This ROI will change depending on whether the price of OHM goes up or down during the vesting period (and to account for the staking rewards earned from the auto-stake).

  • Debt Buffer: This is an internal parameter for the protocol to set as a circuit breaker. It could affect user activity, since it will stop the Market if exceeded, but it shouldn't affect the actions of a single user.
  • Tuning Interval: This is also an internal parameter for the protocol to control how fast the Market reacts to price changes. Therefore, it isn't necessarily applicable to end users and not specified when new Markets are released.

Bond Pricing

One of the biggest advantages of the Olympus Bonds over a naive bond implementation is that the markets automatically adjust their prices over time based on user demand. This may seem like only an administrative automation, but it is also designed to prevent large swings in price caused by manual adjustments and removes a potential insider conflict of interest.

$$ Price = Bond Control Variable * Debt Ratio $$

where

$$ Debt Ratio = Current Debt / Base Supply $$

and

$$ Current Debt = Total Debt - Debt Decay since Last Activity $$

and

$$ Debt Decay since Last Activity = Total Debt * Seconds Since Last Decay / Market Duration $$

Re-writing these equations, we get the following relationship:

$$ Price = Bond Control Variable \ * $$

$$ Total Debt * ( 1 - Seconds Since Last Decay / Market Duration ) / Base Supply $$

We can now breakdown these inputs to see how the price changes over time:

  • Bond Control Variable (BCV) - A positive number that represents the relationship between the Debt Ratio and the Bond Price. It is initialized by dividing the Initial Price by the initial Debt Ratio. The BCV is adjusted by the protocol, via the _tune function, over the life of the Market to hit the Capacity target. Bond Price and BCV are directly related. If BCV increases, Price will increase and vice versa.
  • Total Debt - A time-decayed value representing the relative amount of Base Tokens owed to users at a given time. It is initialized to the Capacity of the Bond Market (or the equivalent amount of OHM for the Capacity at the Initial Price if specified in Quote Tokens), but the Market doesn't actually owe anything at creation so it's not a true debt measure. The Total Debt increases each time a user deposits funds by the amount of Base Token owed to the user. At the same time, the Total Debt is constantly decreasing as time passes. This creates a push and pull relationship on the Bond Price that we'll examine in the next section. One useful insight is that if the Total Market Debt is the same as the Capacity of the Market and the Bond Control Variable has not been tuned, then the Bond Price would equal the Initial Price. Total Debt and Bond Price are directly related. If Total Debt increases, Price will increase and vice versa.
  • Seconds since Last Decay - The difference between the current timestamp and the one saved as the lastDecay in the Market Metadata. Seconds since Last Decay is inversely related to Bond Price. Said another way, the more time that has past since the last debt decay, the more the Total Debt will decrease, which means Bond Price decreases.
  • Market Duration - A constant. The number of seconds from the Market creation to the Expiration provided.
  • Base Supply - The Total Supply of the Base Token. Olympus has a function on their Treasury contract that provides this to the Bond Depository. In some cases, this may be a constant if the Base Token has a fixed supply.

Based on this, we see the Total Debt and Bond Control Variable are the key variables for the protocol to use to manage the Bond Price.

Debt Decay

As we saw in the last section, the Total Debt is decayed over time by the protocol, but increases with user deposits. This dynamic creates a natural balancing affect on the Bond Price. In practice, it means that the longer it has been since a user deposited into the Market, the better the price is. Additionally, if the pace and volume of deposits is slower than the target set by the Market creator, then the price will be lower than initially set. The opposite is also true: if the pace and volume of deposits is greater than the target, then the price will be higher than initially set. The Olympus developers put a great comment in the BondDepository file that illustrates this.

DecayComment.jpg

We will review how this unfolds over the life of a Bond Market in an example below and the details of how this is implemented later in the article.

Tuning

Tuning can be thought of as an extra measure that the protocol implements to self-adjust prices if the time-driven, debt decay does not adjust prices fast enough to hit the target capacity as the market prices of tokens change. Tuning adjusts the Bond Control Variable up or down to reset the Bond Price and Max Payout values so that the Market can hit its Capacity target in the remaining time until Expiration. _tune is called each time a user makes a deposit on the Bond Market, but it only executes a tuning if sufficient time has passed since the last one (the Tuning Interval). In this way, the Tuning Interval controls how fast the protocol reacts to changes in market activity.

Tuning behaves differently for upward and downward adjustments. As a quick recap, an increase in the BCV will increase the Bond Price and a decrease in the BCV will decrease the Bond Price. Based on that, the protocol implements the following protections when adjusting the BCV.

  • When a tune will result in the BCV increasing, it is applied immediately and the Bond Price adjusts upward the full amount.
  • When a tune will result in the BCV decreasing, it is applied incrementally over the Tuning Interval. This protects the protocol from a rapid drop in price that could create opportunities for bots to arbitrage at the expense of other users. As an example, assume a Market is priced at 100 Quote Tokens and the price is to be adjusted down by 6 via a tuning action. Additionally, assume the Tuning Interval set on the Market is 6 hours. Therefore, the price of the bond will be adjusted down by 1 each hour for the next 6 hours instead of all at once.

Bond Market Examples with Simulation

To visualize how the key variables change over the lifespan of a Market, let's walk through a simulated run of the example provided previously: "DAI bonds have a capacity of 8,260 OHM over 7 days which is approximately $300,000 per day with a deposit target of every 6 hours. The bonds will have a 14-day vesting term, are automatically staked, and can be claimed at the end of the vesting term." I changed the duration from 7 days to 14 days to provide more detail in the charts and because the simulator is more stable at this duration.

I have created a Google Sheet to model the Market's behavior with any start parameters and some simulated user activity. You can create a copy of the Sheet and follow along if you wish.

Input Market Parameters

To create the simulation, we input the variables that we extracted from the statement earlier in the article. I am going to make an assumption that the Tuning Interval is the same as the Target Deposit Interval (6 hours). The Variable Flags on the Sheet are used to select which format to provide the input in. A "1" means TRUE and will use the top format in that section. A "0" means FALSE and will use the bottom format in that section.

Ex1_Setup.png

In order to simulate user activity, we need to specify when a user will decide to bond assets. We do that here by assuming a target ROI that they will always buy at. This is a simplistic view of user behavior since different users will have different targets, but it works for demonstration purposes. Additionally, we can set what the prices of each token (base and quote) will be at Market expiration. The simulator will assume a linear (straight line) change from the starting prices to the end prices. This is again simplistic, but it allows you visualize how the Market will generally behave in a neutral, rising, and declining market conditions. I have set the ending prices the same as the starting ones in this case.

Once all parameters are provided, the results will be automatically calculated in the table next to the inputs on the "Simulation Dashboard" tab and the Charts on the other tabs of the Sheet will be updated. The Charts also show the cutoff (red line) for where the Market stops accepting deposits in the Simulation because its Capacity has been reached.

Total Debt

As we saw in the Bond Pricing section, Total Debt is consistently being balanced by a constant time-based decay and user deposits. In this example, the debt starts out pretty stable around the initial value, but it becomes more varied at the end as the Market tries to adjust the price parameters to hit the target capacity. Our simulator breaks down a little bit here because market participants are not all going to act the same, and we are using finite step intervals. It does help us visualize overall trends though. Keep this in mind as we go through the rest of the examples.

Ex1_TD.jpg

The chart looks a lot like the example in shown earlier from the code! We show both the value of the Debt in storage and the Decayed value which is returned by a view function. The main thing to realize here is that the stored debt doesn't change unless a deposit is made and the amount of decay is the time passed since the last deposit multiplied by the old stored amount. I ran into a little bit of a gotcha while building the simulator because of this quirk. To illustrate, let's calculate the decay debt in the following scenario:

  • Start debt = 10,000
  • Time elapsed since last deposit = 2 hours = 7,200 seconds
  • Total duration = 1 day = 86,400 seconds

If you calculated the debt consecutively every hour, you would get the end result as: $$ 10000 * (1 - 3600 / 86400) * (1 - 3600 / 86400) = ~9184 $$

Whereas, if you only store the decay on a deposit, you would get the end result as: $$ 10000 * (1 - 7200 / 86400) = ~9167 $$

Bond Control Variable

In this example, the BCV remains relatively stable at the value initially set for the same reasons as the Total Debt and starts to vary more towards the end. Additionally, the overall market conditions are neutral with no changes in the price of the tokens.

Ex1_BCV.jpg

The chart shows the Calculated BCV (new target calculated by the tuning), the Decayed BCV (amount protocol uses for pricing) and the Stored BCV over time. You can see that they track exactly for upward movements of the BCV, but the Stored BCV lags behind the Calculated and Decayed BCV for downward movements. This is the effect of the delayed adjustments in BCV over the Tuning Interval when it decreases.

Bond Price

Combining these two variables, we can see how the Bond Price and the User ROI at Purchase time changes over the life of the Market. In this example, the Bond Price remains pretty stable at the beginning since both the Total Debt and BCV did as well. The Price and ROI vary towards the end for the same reasons as the other variables.

Ex1_Price.jpg

When the Market is closed, the protocol has received 1,907,166 DAI for the 8,260 OHM it paid out against a target of roughly 2,100,000.

Additional Examples

The following examples show how these charts change with different market conditions and user activity.

Market Price of Base Token Increasing

In this example, the price of the Base Token (OHM) increases by 20% over the life of the Market (from $267 to $320.4). All other variables are the same.

Ex2_TD.jpg

Ex2_BCV.jpg

Ex2_Price.jpg

We can see that the Total Debt chart is nearly unchanged from the base example, but the BCV increases to keep up with market prices. The Bond Price increases with the BCV, and the user's ROI at purchase remains roughly the same as the base example.

When the Market is closed, the protocol has received 2,143,245 DAI for the 8,260 OHM it paid out. The increased price of OHM resulted in the protocol receiving more DAI than anticipated.

Market Price of Base Token Decreasing

In this example, the price of the Base Token (OHM) increases by 20% over the life of the Market (from $267 to $213.6). All other variables are the same.

Ex3_TD.jpg

Ex3_BCV.jpg

Ex3_Price.jpg

We can see that the Total Debt chart is nearly unchanged from the base example, but the BCV decreases to keep up with market prices. The Bond Price decreases with the BCV, and the user's ROI at purchase remains roughly the same as the base example. This is the opposite effect of the previous example, as we would expect. However, it is interesting to see that Adjustment framework ensures the BCV doesn't immediately change like the upward adjustments.

When the Market is closed, the protocol has received 1,760,372 DAI for the 8,260 OHM it paid out. The decreased price of OHM resulted in the protocol receiving fewer DAI than anticipated.

User Target ROI Higher than Projected

In this example, we look at how the Market responds when users have a higher target ROI than the Initial Price set by the Market creator. This example is a bit contrived since all users will likely have different return targets and, in the case of an efficient market, would settle at a value towards the bottom of the range that a cohort of users would accept. Even so, it is useful to see how this type of activity would affect the Market. Here we set the Target User ROI for the simulation to 9% from 5%. All other variables remain the same.

Ex4_TD.jpg

Ex4_BCV.jpg

Ex4_Price.jpg

We can see that Total Debt starts decreasing after the Market is created and there is a big downward adjustment for the BCV. Price drops as both of those changes take effect until the ROI reaches the Users' Target. After the initial jump, the variables stabilize.

When the Market is closed, the protocol has received 1,868,260 for the 8,260 OHM it paid out. The increased ROI target of users resulted in the protocol receiving fewer DAI than anticipated.

User Target ROI Lower than Projected

In this example, we look at how the Market responds when users have a lower target ROI than the Initial Price set by the Market creator. A similar caveat to the previous example applies. Here we set the Target User ROI for the simulation to 1% from 5%. All other variables remain the same.

Ex5_TD.jpg

Ex5_BCV.jpg

Ex5_Price.jpg

The effect of this change is the opposite of the previous example. We can see that Total Debt initial increases due to high deposit activity after the Market is created and there is a big upward adjustment for the BCV. Price increases as both of those changes take effect until leveling off when the ROI dips down to the users' acceptable target ROI. After the initial increase, the variables stabilize.

When the Market is closed, the protocol has received 2,009,132 for the 8,260 OHM it paid out. The decreased ROI target of users resulted in the protocol receiving more DAI than anticipated.

Debt Buffer Reduction

In the base and previous examples, I used a Debt Buffer of 10% which resulted in the Max Debt circuit breaker not being triggered in any of the examples. Let's look at what can happen if it's set too low. In this example, we use a Debt Buffer of 5%.

Ex6_TD.jpg

Ex6_BCV.jpg

Ex6_Price.jpg

The Market proceeds normally at first, but the price doesn't adjust fast enough downward and a deposit at the Max Payout crosses the Max Debt ceiling, shutting off the Market. Therefore, it's important that the Debt Buffer be high enough to allow for normal market activities while still being able to protect the protocol as designed. There is no specific cut-off for the value. It would depend on how volatile the assets being bonded or paid out are, Market duration, and the deposit interval.

Further Study

There are many other possible variations and market conditions that could be explored with the simulator, including changes in the price of the Quote Token, altering the Target Deposit and Tuning Intervals, changing the Market Duration, etc.

Code Walk-through

After covering the concepts and math behind Olympus Bonds in probably way more detail than you cared for, we're now going to walk through the Solidity contracts.

First, I want to give credit to Zeus and Indigo for writing well-commented and easy to read code. The V2 contracts conform to NatSpec and are filled with detailed comments. On top of that, the auto-adjusting Bond Pricing structure is very cool and the piece of the protocol that I'm most excited to review.

The main contract to review within the Olympus Bonding framework is BondDepository.sol. However, the BondDepository inherits functionality from a few other contracts so we will review these in chunks since that is how the team decided to split them up. Specifically, the inheritance tree is:

  • BondDepository is ...
    • IBondDepository &
    • NoteKeeper, which is ...
      • INoteKeeper &
      • FrontEndRewarder, which is ...
        • OlympusAccessControlled

We'll start from the bottom and work our way up to the BondDepository contract to pull it all together. The first couple contracts are fairly straightforward so I won't spend a lot of time there. One thing to keep in mind with the Olympus V2 contracts is that the interface contracts often serve a dual purpose: Defining data structures used on the main contract (which inherits the interface) and providing the traditional interface role of referencing the contract from another one in the protocol.

OlympusAccessControlled.sol

OlympusAccessControlled is a simple contract that manages which addresses have permissions across the four main roles in the Olympus protocol: Governor, Guardian, Policy, and Vault. The contract also provides function modifiers to restrict access to a specific role. OlympusAuthority.sol implements this contract as well and provides push/pull functions to assign the roles. This is similar to the OpenZeppelin Ownable and AccessControl contracts. As a practical matter, the roles are currently set to these addresses in production:

FrontEndRewarder.sol

The purpose of the FrontEndRewarder contract is to manage the accrual and distribution of rewards to the DAO and Front-end Operators (FEOs) that are earned when users deposit funds to the BondDepository. The contract is abstract, which means it is not meant to be deployed by itself.

The contract has the following functionality:

  • Allows the Governor role to set the reward rates for the DAO and FEOs -> setRewards(uint256 _toFrontEnd, uint256 _toDAO)
  • Allows the Policy role to whitelist new FEO addresses for rewards -> whitelist(address _operator)
  • Allows the DAO and FEOs to withdraw their current reward balance in OHM

    // pay reward to front end operator
    function getReward() external {
      uint256 reward = rewards[msg.sender];
    
      rewards[msg.sender] = 0;
      ohm.transfer(msg.sender, reward);
    }
    

    Rewards due to each address are stored in a mapping, rewards. The amount accrues more tokens each time _giveReward is called (this happens on a user deposit in the BondDepository). When the user retrieves their balance, the OHM is transferred from the contract and their balance is reset.

INoteKeeper.sol

The INoteKeeper contract implements the data structures used in the NoteKeeper contract and provides the external function specifications.

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity >=0.7.5;

interface INoteKeeper {
    // Info for market note
    struct Note {
        uint256 payout; // gOHM remaining to be paid
        uint48 created; // time market was created
        uint48 matured; // timestamp when market is matured
        uint48 redeemed; // time market was redeemed
        uint48 marketID; // market ID of deposit. uint48 to avoid adding a slot.
    }

    function redeem(
        address _user,
        uint256[] memory _indexes,
        bool _sendgOHM
    ) external returns (uint256);

    function redeemAll(address _user, bool _sendgOHM) external returns (uint256);

    function pushNote(address to, uint256 index) external;

    function pullNote(address from, uint256 index) external returns (uint256 newIndex_);

    function indexesFor(address _user) external view returns (uint256[] memory);

    function pendingFor(address _user, uint256 _index) external view returns (uint256 payout_, bool matured_);
}

The Note struct shows the data stored for each user Note to be paid out. One clever thing done here is the use of the uint48 variable size inside of structs to store timestamps. Timestamps on the EVM are stored in Unix format, which means the number of seconds since the Unix Epoch (January 1st, 1970 at 00:00:00 UTC). The current Unix timestamp is 1,643,056,135 seconds. A uint48 can store an integer up to 281,474,976,710,655. Therefore, it won't overflow until ~8.9 million years from now. A smaller size, like uint32 would overflow in ~150 years. Of course, you could just use the max size and never worry about it, but 8.9 million years is probably enough time. In addition, since these variables are inside a struct, the four uint48 values will only take up one 256-bit storage slot. This saves gas on the creation of each data element.

The external functions here can be broken into a couple main sections:

  • Retrieving payouts once Notes are vested: redeem and redeemAll
  • Note transfers (NFT-like functionality): pushNote and pullNote
  • View functions for getting Notes for a particular user: indexesFor and pendingFor We'll dive into these in the next section.

NoteKeeper.sol

The purpose of the NoteKeeper contract is to handle all the data and functionality related to the Notes users receive when they deposit funds in the BondDepository.

To keep track of user information and actions, the contract implements these data structures:

mapping(address => Note[]) public notes; // user deposit data
mapping(address => mapping(uint256 => address)) private noteTransfers; // change note ownership

The contract has the following functionality:

  • Add a new Note (on user deposit)
  • Transfer a Note to another address
  • View pending Notes and get the status of a Note
  • Update Treasury address to mint payout rewards from

Add a new Note

/**
 * @notice             adds a new Note for a user, stores the front end & DAO rewards, and mints & stakes payout & rewards
 * @param _user        the user that owns the Note
 * @param _payout      the amount of OHM due to the user
 * @param _expiry      the timestamp when the Note is redeemable
 * @param _marketID    the ID of the market deposited into
 * @return index_      the index of the Note in the user's array
 */
function addNote(
    address _user,
    uint256 _payout,
    uint48 _expiry,
    uint48 _marketID,
    address _referral
) internal returns (uint256 index_) {
    // the index of the note is the next in the user's array
    index_ = notes[_user].length;

    // the new note is pushed to the user's array
    notes[_user].push(
        Note({
            payout: gOHM.balanceTo(_payout),
            created: uint48(block.timestamp),
            matured: _expiry,
            redeemed: 0,
            marketID: _marketID
        })
    );

    // front end operators can earn rewards by referring users
    uint256 rewards = _giveRewards(_payout, _referral);

    // mint and stake payout
    treasury.mint(address(this), _payout + rewards);

    // note that only the payout gets staked (front end rewards are in OHM)
    staking.stake(address(this), _payout, false, true);
}

The addNote function is called from the BondDepository deposit function that we'll review shortly. The parameters align with the information needed to create a new Note:

  • The user is the address that called deposit and who will own the Note. Notes are stored in a mapping from address to a dynamic array of Notes: mapping(address => Note[]) notes. This means each user has a list of Notes that they own.
  • The payout value is computed in the deposit function as a number of OHM and passed here (as seen in the diagram above). The amount is then converted to a number of gOHM to store in the Note.
  • The expiry value is the current timestamp plus the vesting period (or the fixed vesting expiration).
  • The marketId value is the ID of the Bond Market that the Note is being added for.
  • The referral value is the address of the Front End Operator (FEO) that gets the reward for the bond.

The _giveRewards function is called with the referral (FEO) address and the payout amount to calculate and allocate DAO and FEO rewards.

OHM is minted from the treasury to cover both the payout and rewards. The payout is then staked in the Staking contract and the contract receives gOHM (the false in the 3rd param means to send the non-rebasing token) to later pay to the user. This is where the "auto-staking" of the V2 Bonds is implemented.

Transfer Notes

OlympusDAO has implemented new functionality in the V2 Bonds to make the debt (i.e. Notes) payable to users after they bond assets transferable to other users. This provides liquidity for users should they need it during the vesting period and opens up the possibility for a credit market (e.g. US Treasuries are tradable). Here is a short-clip reviewing the pushNote and pullNote functions that enable this feature.

This is an interesting feature that has been added, but it will require OlympusDAO or other developers to build solutions on top of the basics to create a true market.

Redeeming Notes

The Note redemption functionality in the V2 Bonds is simple, but an improvement over the V1 Bonds by allowing users to claim payouts from multiple bonds at once. This is mostly a result of the BondDepository being able to track multiple markets and notes per market for each user. The following video walks through the redeem, redeemAll, and helper functions.

IBondDepository.sol

The IBondDepository contract implements the data structures used in the BondDepository contract to track Bond Markets and provides the external function specifications.

// SPDX-License-Identifier: AGPL-3.0
pragma solidity >=0.7.5;

import "./IERC20.sol";

interface IBondDepository {
    // Info about each type of market
    struct Market {
        uint256 capacity; // capacity remaining
        IERC20 quoteToken; // token to accept as payment
        bool capacityInQuote; // capacity limit is in payment token (true) or in OHM (false, default)
        uint64 totalDebt; // total debt from market
        uint64 maxPayout; // max tokens in/out (determined by capacityInQuote false/true, respectively)
        uint64 sold; // base tokens out
        uint256 purchased; // quote tokens in
    }

    // Info for creating new markets
    struct Terms {
        bool fixedTerm; // fixed term or fixed expiration
        uint64 controlVariable; // scaling variable for price
        uint48 vesting; // length of time from deposit to maturity if fixed-term
        uint48 conclusion; // timestamp when market no longer offered (doubles as time when market matures if fixed-expiry)
        uint64 maxDebt; // 9 decimal debt maximum in OHM
    }

    // Additional info about market.
    struct Metadata {
        uint48 lastTune; // last timestamp when control variable was tuned
        uint48 lastDecay; // last timestamp when market was created and debt was decayed
        uint48 length; // time from creation to conclusion. used as speed to decay debt.
        uint48 depositInterval; // target frequency of deposits
        uint48 tuneInterval; // frequency of tuning
        uint8 quoteDecimals; // decimals of quote token
    }

    // Control variable adjustment data
    struct Adjustment {
        uint64 change;
        uint48 lastAdjustment;
        uint48 timeToAdjusted;
        bool active;
    }

    function deposit(
        uint256 _bid,
        uint256 _amount,
        uint256 _maxPrice,
        address _user,
        address _referral
    )
        external
        returns (
            uint256 payout_,
            uint256 expiry_,
            uint256 index_
        );

    function create(
        IERC20 _quoteToken, // token used to deposit
        uint256[3] memory _market, // [capacity, initial price]
        bool[2] memory _booleans, // [capacity in quote, fixed term]
        uint256[2] memory _terms, // [vesting, conclusion]
        uint32[2] memory _intervals // [deposit interval, tune interval]
    ) external returns (uint256 id_);

    function close(uint256 _id) external;

    function isLive(uint256 _bid) external view returns (bool);

    function liveMarkets() external view returns (uint256[] memory);

    function liveMarketsFor(address _quoteToken) external view returns (uint256[] memory);

    function payoutFor(uint256 _amount, uint256 _bid) external view returns (uint256);

    function marketPrice(uint256 _bid) external view returns (uint256);

    function currentDebt(uint256 _bid) external view returns (uint256);

    function debtRatio(uint256 _bid) external view returns (uint256);

    function debtDecay(uint256 _bid) external view returns (uint64);
}

Similar to the INoteKeeper contract, I'm going to focus on the the Data Structures in the interface and then review the functions when we get to their implementation in the BondDepository contract below.

The IBondDepository data structures are interesting because the first three structs (Market, Term, and Metadata) are all related to a Market and could be organized in several ways.

One explanation I could derive for how they are structured is to optimize gas costs on the deposit function. This function is the only non-view function that will be called by a regular user; therefore, it would be the prime target for optimization. With that in mind, we can see that each struct contains data with a specific characteristic (with some exceptions):

  • Market - Data that needs to be updated when a deposit transaction is sent.
    • capacityInQuote, quoteToken, and maxPayout are not updated, but they are related to the capacity variable and are stored with it.
  • Terms - Data that needs to be read when a deposit transaction is sent.
  • Metadata - Data that is not used in the deposit function (it is used in the internal functions it calls though).

Additionally, the variables are grouped into the structs next to data with similar units. All of the variables in Metadata are units of time except for quoteDecimals (which is stored as an optimization instead of being called each time it's needed).

Finally, the Terms and Metadata structs each only take up 1 storage slot since they take up 232 and 248 bytes respectively. I didn't find any specific cases where this would make a large difference, but I thought it was interesting to consider.

We can see that the variables we reviewed earlier that are required to create a Market and calculate the current price at any time during its life are stored in these data structures.

The Adjustment struct is specific to downward tuning adjustments of the Bond Control Variable.

BondDepository.sol

Finally, we get to the BondDepository contract which pulls all the previous code together and implements the main Market management, user deposit, and pricing logic.

Storage

The storage layout for the contract is very straightforward. There is a dynamic array for each of the three main structs which is used to store information about each Market that is created. Each array will always be the same size because a single item is added to each on the creation of a Market. As a result, the items corresponding to a specific Market will have the same index (referred to as an id) in each array. This simplifies data access in multiple functions. There is also a mapping to store Adjustments. Each Market can only have one Adjustment at a time and so the mapping is from the Market id to the current Adjustment.

Apart from storage for the structs we discussed previously, there is a mapping from an address to an array of unsigned integers that collects all the Market IDs for a given Quote Token in one place. This is used to provide efficient queries to front-end interfaces, as we'll see later.

// Storage
Market[] public markets; // persistent market data
Terms[] public terms; // deposit construction data
Metadata[] public metadata; // extraneous market data
mapping(uint256 => Adjustment) public adjustments; // control variable changes

// Queries
mapping(address => uint256[]) public marketsForQuote; // market IDs for quote token

Create and Close a Market

New Markets can be created by the Olympus Policy Team by calling the create function. In order to do so, they have to provide the parameters that we studied earlier in the Bond Market simulation. To keep the function interface clean, they have been grouped into a couple different arrays. Here is a video where I walk through the implementation.

Once created, the Market will automatically end once the capacity or conclusion time is reached. However, if required, the Olympus Policy Team can end a Market early by calling the close function. This will update the conclusion of the Market to the current timestamp and set the capacity of the Market to zero to prevent any more deposits.

Interface View Functions

In order for the Bond Depository to be practically useful, we need some view functions that user interfaces can query to get information about the current Markets available and their prices. Olympus has implemented several of these for different purposes:

  • marketPrice - provides the current price of a Market in Quote Tokens per Base Token (in base token decimals) and accounts for the decay of Debt and the Control Variable.
  • payoutFor - provides the current amount of Base Tokens for an amount of Quote Tokens on a Market, accounting for the decay of Debt and the Control Variable.
  • liveMarkets - returns the list of Market IDs that are active. Uses isLive.
  • liveMarketsFor - returns the list of Market IDs that are active for a specific Quote Token. Uses isLive.

Additionally, there are several public helper functions which are leveraged by the view functions and can be called by external contracts or interfaces for information about the Bond Markets.

  • debtRatio - provides the current debt ratio, accounting for the decay of Debt and the Control Variable. Used by marketPrice.
  • currentDebt - provides the current Debt, accounting for decay. Used by debtRatio.
  • debtDecay - provides the amount of Debt to decay at the current time from the last decay, used by currentDebt.
  • currentControlVariable - provides the current Control Variable value, accounting for decay. Used by marketPrice.
  • isLive - returns whether a given Market is accepting deposits. Used by liveMarkets and liveMarketsFor.

While most of these are fairly straightforward, there are a couple interesting observations. I walk through the implementations in these videos.

Price View Functions
Live Markets View Functions

Lastly, there are also internal view functions _marketPrice and _debtRatio which provide the same calculations as the public functions without accounting for current decay. Other functions, like deposit, use these in cases where the stored values for Debt and the Control Variable have just been set so that there isn't any decay to apply. This saves gas in cases where we know the extra operations will result in zero values.

User Deposits

Users bond assets with the Bond Depository using the deposit function. In addition, the deposit function is where all of the state changes are saved for Market variables updates over its lifespan. The function has a number of checks to ensure that the deposit is allowed to proceed and to protect the user from front-running bots. I walk through it in the following video:

Clarity over Cleverness

Overall, the brilliance of these contracts is not in obscure programming optimizations, but in the protocol's design and the clarity of the implementation. When broken down into components and the logic is understood, the actual code is fairly plain. This is a good thing. Complexity and irregular patterns can hide security issues that might otherwise be seen by reviewers.

Changes from Olympus V1

Bonds were completely revamped in the migration from V1 to V2, providing significant upgrades from a user and protocol management perspective.

  • New BondDepository allows multiple bonds (called Markets) of any type (even multiple for the same asset) to be created and managed from one contract.
    • Allowing multiple Markets for the same asset allows the Policy team provide different expirations, discounts, vesting terms, etc. options to investors.
  • Specific user amounts owed are tracked as Notes. Users are now able to claim all vested payouts from active notes at once, saving gas costs over the previous implementation. Notes are also transferable between addresses using transfer functions on the contract (like an NFT, but not a separate token contract).
  • Markets now expire at a specific date/time specified when created and are limited to a capacity. The idea is that the Policy team is actively managing the bonds to provide offerings based on protocol treasury goals and market conditions. Instead of constantly tweaking parameters, Markets can be opened with specific objectives.
  • Vesting terms can be fixed-duration (e.g. 14 days - same as V1) or fixed expiration (e.g. available until 1/31/2022).
  • Payouts are automatically staked while they are vesting now so any Market with a positive discount is strictly superior to buying and staking. This slight change does a lot to better align protocol and user incentives.

A major theme is limiting what needs to be done on-chain to the minimum possible and performing other calculations off-chain (e.g. on the front-end). An example of this is Bond Prices and Calculators:

  • V1 Bonds had functions for getting the Bond Price in USD and leveraged a Bonding Calculator to determine the Risk Free Value of LP tokens as well as converting prices into OHM units.
  • V2 Bonds do not have on-chain price information for converting token amounts to USD. This must be done on an external interface.