I have been involved with the CRE8R DAO, the Web3 content marketing DAO. Clients come to us to get content marketing services. Typically, the DAO charges them a fixed amount in stablecoins or an equivalent in ETH. Sometimes, they want to pay us in their governance tokens. This is a good deal for our clients because to them governance tokens are basically free. However, this can be a great or a horrible deal for us because of volatility. While this is a very risky treasury management strategy for the DAO, by accepting some payments in governance tokens, we show our commitment to grow with our clients and differentiate ourselves from other agencies.
Realistically, we cannot expect our contributors to be paid in governance tokens because they expect to be paid a predictable value. In order to pay them, we would have to sell the governance tokens we have just accepted into stablecoins. This creates selling pressure (even if not a significant amount, but not all our clients have deep liquidity pools) and it doesn’t align with our goal to grow with our clients.
We started exploring ways to accept governance tokens without selling them, while able to pay our contributors a predictable value. A few months ago, it was OHM SZN and there was literally at least an OHM fork launching every day. We thought it might be interesting to see if we can take our clients’ tokens as bonds, and then use them to back a new token. Obviously it would be reckless to back this token with only our clients’ tokens as their value are very volatile, so the majority of the backing would still come from stablecoins.
We have decided to pause the experiment due to the recent negative market sentiment towards OHM forks, but I would still like to share the inner workings of the protocol (V1, sorry it is not V2 but I hope you still learn a thing or two) and share my deployment script for educational purposes. You can find my code on GitHub. There are plenty of materials on how Olympus works so I will jump straight into the code.
The architecture
In the Olympus protocol, users do not interact with the treasury directly. There is a bond depository contract for each reserve and liquidity bond. Users purchase bonds and redeem OHM through calling their functions. Bond depositories do not hold any assets as they are immediately transferred to the treasury contract. The treasury is the protocol’s vault. The bond depositories relies on the treasury to calculate the value of a token/LP to be bonded. It also has the ability to mint new OHM to be sent to the staking contract, which is then distributed to the stakers. The amount of OHM that can be minted depends on the amount of reserves in the treasury that is not currently backing any OHM. Excess reserves can also be withdrawn by authorized parties (usually yield strategy contracts, e.g. the AAVE/Convex/Onsen allocator) to make extra revenue for the protocol.
The tokens in this protocol
OHM
OHM is the protocol’s reserve currency and it can be found at OlympusERC20Token. It has 9 decimals and it can only be minted by the vault (the treasury). The vault address can only be set after the treasury has been deployed.
constructor(address _authority) ERC20(“Olympus”, “OHM”, 9) … {}
function mint(address account_, amount_) external override onlyVault { … }
sOHM
Next up, there is sOHM. sOHM is the staked version of OHM and it rebases every epoch (2,200 blocks). It is suitable to rely on block numbers on Ethereum because block time is relatively stable, but other chains have a different mechanism on block time so block number is not a reliable way to determine epoch lengths. Many forks decide to modify the protocol to rely on block.timestamp
instead of block.number
to measure epoch lengths.
In sOHM, there is a concept called the gons
(which I believe comes from Ampleforth) as the hidden account for balance. A staker’s external balance is his gon balance divided by the contract’s gons per fragment.
function balanceOf( address who ) public view override returns ( uint256 ) {
return _gonBalances[ who ].div( _gonsPerFragment );
}
The reason to have an external and an internal balance representation is to adjust staker balances in a gas efficient manner. It is gas intensive if the contract has to increase each staker’s balance one by one in every rebase. In fact, I doubt it is even possible to do it within the block gas limit of 30m as there are around 23,676 sOHM holders and each SSTORE update cost 5,000. Rebase works by adjusting the single variable _gonsPerFragment
. Given the balanceOf
function above, the smaller the _gonsPerFragment
, the bigger the actual balance. sOlympusERC20 has a function called rebase
that is only callable by the staking contract, what it does is to tell sOHM to rebase given the profit generated by a given epoch. The rebaseAmount
is being added to the _totalSupply
and _gonsPerFragment
is reduced as _totalSupply
increases.
rebaseAmount = profit_.mul(_totalSupply).div(circulatingSupply_);
_totalSupply = _totalSupply.add(rebaseAmount);
_gonsPerFragment = TOTAL_GONS.div(_totalSupply);
As sOHM is not a standard ERC-20 token, its transfer functions also have to account for its way of accounting. They accept the argument value
, which is the external balance representation. It then converts it back to its gon value and adjusts the sender and receiver’s gon balances.
uint256 gonValue = value.mul(_gonsPerFragment);
_gonBalances[msg.sender] = _gonBalances[msg.sender].sub(gonValue);
_gonBalances[to] = _gonBalances[to].add(gonValue);
wsOHM (or gOHM in V2)
I will refer to the V1 version as I spent most of my time going through the V1 contracts. The original proposal on why there should be a wrapped OHM can be found here. wsOHM does not rebase, but its value goes up as its underlying sOHM rebases. The functions wrapFromsOHM
and unwrapTosOHM
lets an sOHM holder wraps and unwraps. The exchange rate between wsOHM and sOHM is determined by the function wsOHMTosOHM
and sOHMTowsOHM
. The value of wsOHM appreciates against sOHM unidirectionally as sOHM rebases. The variable index
below is an immutable value, which is really just a placeholder to account for the increase in sOHM balance for any staked address.
function wsOHMTosOHM( uint _amount ) public view returns ( uint ) {
return _amount.mul( IERC20( sOHM ).balanceOf( index ) ).div( 10 ** decimals() );
}
function sOHMTowsOHM( uint _amount ) public view returns ( uint ) {
return _amount.mul( 10 ** decimals() ).div( IERC20( sOHM ).balanceOf( index ) );
}
Buying a bond
To buy bonds, a user calls the OlympusBondDepository’s function deposit
. It calculates the bond price, makes sure it is not higher than the allowed slippage, calculates the amount of OHM to pay out (after fee), deposits the asset into the treasury, and creates a bond that vests for 5 days. The user will be able to redeem his OHM as time elapses.
Each time when a deposit happens, debt is decayed (reduced) by the amount of debt that has been vested. The definition of debt is the amount of OHM owed to bonders that have not yet been vested. Vested OHM is no longer considered debt.
function debtDecay() public view returns ( uint decay_ ) {
uint blocksSinceLast = block.number.sub( lastDecay );
decay_ = totalDebt.mul( blocksSinceLast ).div( terms.vestingTerm );
if ( decay_ > totalDebt ) {
decay_ = totalDebt;
}
}
There is a debt ceiling for each bond and the total bond debt after debt decay cannot exceed it.
require( totalDebt <= terms.maxDebt, "Max capacity reached" );
If the debt ceiling is not breached, the function continues to calculate the bond price in USD and the actual bond price. The bond price in USD is not really used for anything except as an input for event emission. The actual bond price is calculated based on the bond control variable and the bond depository’s debt ratio. The higher the bond control variable and debt ratio, the higher the price. Bond price is lower when the protocol needs to attract bonders to thicken protocol owned liquidity and vice versa. There is a minimum that the bond price cannot fall below. The minimum price’s unit is in cents and without the decimals. The bond price also cannot be higher than the maximum price provided by the user (or actually the frontend).
function _bondPrice() internal returns ( uint price_ ) {
price_ = terms.controlVariable.mul( debtRatio() ).add( 1000000000 ).div( 1e7 );
if ( price_ < terms.minimumPrice ) {
price_ = terms.minimumPrice;
} else if ( terms.minimumPrice != 0 ) {
terms.minimumPrice = 0;
}
}
I am still quite not sure what the line terms.minimumPrice = 0;
is supposed to do (if you happen to know, maybe you can leave a comment).
The debt ratio is the bond depository’s current debt (OHM not vested) over the total supply of OHM.
function debtRatio() public view returns ( uint debtRatio_ ) {
uint supply = IERC20( OHM ).totalSupply();
debtRatio_ = FixedPoint.fraction(
currentDebt().mul( 1e9 ),
supply
).decode112with18().div( 1e18 );
}
For a reserve asset such as DAI, Olympus sees the value of 1 OHM to be worth at least 1 DAI. Hence “1 OHM is backed by 1 DAI and it cannot fall below the risk-free value, but it can be freely traded above its risk-free value” (turns out it can also trade below risk-free value for a loooong time if no one is defending it). The calculation below basically says “1 to 1”, but decimal conversion has to be done due to the fact that OHM only has 9 decimals.
value_ = _amount.mul( 10 ** IERC20( OHM ).decimals() ).div( 10 ** IERC20( _token ).decimals() );
If the bonded asset is an LP token, it uses another contract called StandardBondingCalculator to calculate the asset value. It gets the total value of the pool and then multiplies by the fraction of LP tokens deposited as a percentage of total LP token supply.
function getTotalValue( address _pair ) public view returns ( uint _value ) {
_value = getKValue( _pair ).sqrrt().mul(2);
}
function valuation( address _pair, uint amount_ ) external view override returns ( uint _value ) {
uint totalValue = getTotalValue( _pair );
uint totalSupply = IUniswapV2Pair( _pair ).totalSupply();
_value = totalValue.mul( FixedPoint.fraction( amount_, totalSupply ).decode112with18() ).div( 1e18 );
}
The total value is 2 times the square root of the LP token’s K value because1
x * y = k (Uniswap V2 formula)
assuming the LP token is OHM-DAI, and Olympus sees OHM/DAI as 1:1
with x = y, x * y = k becomes x^2 = k, x = sqrt(k)
y is also equal to sqrt(k) because x = y.
so x + y (the value of OHM + DAI) becomes 2 * sqrt(k).
While the LP token’s value is fully accounted for in the formula above, Olympus marks down the RFV of OHM-DAI in its presentation because the LP token consists of OHM and using OHM to back OHM creates circular dependency.
function markdown( address _pair ) external view returns ( uint ) {
( uint reserve0, uint reserve1, ) = IUniswapV2Pair( _pair ).getReserves();
uint reserve;
if ( IUniswapV2Pair( _pair ).token0() == OHM ) {
reserve = reserve1;
} else {
reserve = reserve0;
}
return reserve.mul( 2 * ( 10 ** IERC20( OHM ).decimals() ) ).div( getTotalValue( _pair ) );
}
The payout amount in OHM is the deposited asset’s value divided by the bond price.
function payoutFor( uint _value ) public view returns ( uint ) {
return FixedPoint.fraction( _value, bondPrice() ).decode112with18().div( 1e16 );
}
On top of the payout, the contract charges a fee (last time I checked it was 100%, so for each OHM minted from a bond, an additional OHM is minted for the DAO) and sends it to Olympus DAO. The remaining asset value after the bond payout and fee goes to the treasury as profit. Profit here means the asset is not used to back the newly minted OHM and can be distributed to stakers during rebase or allocated to approved yield strategy contracts.
uint fee = payout.mul( terms.fee ).div( 10000 );
uint profit = value.sub( payout ).sub( fee );
/* In the treasury contract */
send_ = value.sub( _profit );
IERC20Mintable( OHM ).mint( msg.sender, send_ );
totalReserves = totalReserves.add( value );
Finally, the contract stores a bond record so that the depositor can come back later blocks to claim his vested payout. The vesting resets the clock even if the depositor has any previous payout, so it is not optimal to bond while you are still vesting.
bondInfo[ _depositor ] = Bond({
payout: bondInfo[ _depositor ].payout.add( payout ),
vesting: terms.vestingTerm,
lastBlock: block.number,
pricePaid: priceInUSD
});
After each deposit, the function adjust
is called to check whether the bond control variable can be updated. It is based on the contract’s adjustment parameters. There are a few conditions:
There is a block time buffer between each adjustment.
uint blockCanAdjust = adjustment.lastBlock.add( adjustment.buffer );
The adjustment rate is nonzero (add flag set to true to increase, else decrease).
if( adjustment.rate != 0 && block.number >= blockCanAdjust ) {
...
}
If the above conditions are met, the bond control variable increases/decreases by the rate
. If it hits the target after the adjustment, the adjustment rate becomes 0 and there will no longer be any change in bond control variable until the policy team changes it again.
terms.controlVariable = terms.controlVariable.add( adjustment.rate );
if ( terms.controlVariable >= adjustment.target ) {
adjustment.rate = 0;
}
Redeeming a bond
To redeem a bond, a user calls the OlympusBondDepository’s function redeem
. Since there is a vesting period, the amount of redeemable OHM depends on the blocks elapsed since the last redeem or the beginning of vesting.
function percentVestedFor( address _depositor ) public view returns ( uint percentVested_ ) {
Bond memory bond = bondInfo[ _depositor ];
uint blocksSinceLast = block.number.sub( bond.lastBlock );
uint vesting = bond.vesting;
if ( vesting > 0 ) {
percentVested_ = blocksSinceLast.mul( 10000 ).div( vesting );
} else {
percentVested_ = 0;
}
}
The redeemed amount is then subtracted from the bond’s total payout and the last redeemed block is updated to the current block.
uint payout = info.payout.mul( percentVested ).div( 10000 );
// store updated deposit info
bondInfo[ _recipient ] = Bond({
payout: info.payout.sub( payout ),
vesting: info.vesting.sub( block.number.sub( info.lastBlock ) ),
lastBlock: block.number,
pricePaid: info.pricePaid
});
The redeemer has the option to redirect the redeemed tokens for staking or to receive the redeemed tokens in his wallet through the _stake
flag.
function stakeOrSend( address _recipient, bool _stake, uint _amount ) internal returns ( uint ) {
if ( !_stake ) { // if user does not want to stake
IERC20( OHM ).transfer( _recipient, _amount ); // send payout
} else { // if user wants to stake
if ( useHelper ) { // use if staking warmup is 0
IERC20( OHM ).approve( stakingHelper, _amount );
IStakingHelper( stakingHelper ).stake( _amount, _recipient );
} else {
IERC20( OHM ).approve( staking, _amount );
IStaking( staking ).stake( _amount, _recipient );
}
}
return _amount;
}
All the OlympusStaking contract function stake
does is to transfer the vested OHM to itself, updates the redeemer’s staking details and transfers sOHM to the warmup contract. The governor can set a warmup period so that the redeemer cannot receive his sOHM until after the warmup period, but he will receive sOHM in the same block if there isn’t a warmup period.
warmupInfo[ _recipient ] = Claim ({
deposit: info.deposit.add( _amount ),
gons: info.gons.add( IsOHM( sOHM ).gonsForBalance( _amount ) ),
expiry: epoch.number.add( warmupPeriod ),
lock: false
});
The claim
function is also called during redemption if the bond depository is using the StakingHelper contract instead of the Staking contract. The policy team has the option to put a staking helper in between the bond depository and the staking logic.
function claim ( address _recipient ) public {
Claim memory info = warmupInfo[ _recipient ];
if ( epoch.number >= info.expiry && info.expiry != 0 ) {
delete warmupInfo[ _recipient ];
IWarmup( warmupContract ).retrieve( _recipient, IsOHM( sOHM ).balanceForGons( info.gons ) );
}
}
Rebasing
There is a rebase every 2,200 blocks, it can either be triggered during staking or by anyone as the rebase
function is an external function.
The staking contract stores a struct called epoch
that records the current epoch state.
epoch = Epoch({
length: _epochLength,
number: _firstEpochNumber,
endBlock: _firstEpochBlock,
distribute: 0
});
The variable
length
is the number of blocks that must have elapsed for a new epoch to begin.The variable
number
is used together with a claim’sexpiry
to determine whether it is time for a bonder to redeem his sOHM.The variable
endBlock
is used to check againstblock.number
to determine whether a new epoch has begun.The variable
distribute
is the amount of OHM to be distributed to stakers for the current epoch.
function rebase() public {
if( epoch.endBlock <= block.number ) {
IsOHM( sOHM ).rebase( epoch.distribute, epoch.number );
epoch.endBlock = epoch.endBlock.add( epoch.length );
epoch.number++;
if ( distributor != address(0) ) {
IDistributor( distributor ).distribute();
}
uint balance = contractBalance();
uint staked = IsOHM( sOHM ).circulatingSupply();
if( balance <= staked ) {
epoch.distribute = 0;
} else {
epoch.distribute = balance.sub( staked );
}
}
}
The rebase
function increases sOHM total supply by epoch.distribute
as a way to increase each sOHM holder’s balance (as mentioned in the sOHM section). Then it proceeds to prepare for the next epoch by updating the epoch end block, number as well as the amount of sOHM to distribute, which is the difference between the staking contract’s OHM balance and sOHM’s circulating supply (the circulating supply excludes the staking contract’s sOHM balance). The difference signals that there is excess OHM to be distributed to sOHM holders. The staking contract’s OHM holdings are minted in the StakingDistributor contract’s distribute
function.
function distribute() external returns ( bool ) {
if ( nextEpochBlock <= block.number ) {
nextEpochBlock = nextEpochBlock.add( epochLength ); // set next epoch block
// distribute rewards to each recipient
for ( uint i = 0; i < info.length; i++ ) {
if ( info[ i ].rate > 0 ) {
ITreasury( treasury ).mintRewards( // mint and send from treasury
info[ i ].recipient,
nextRewardAt( info[ i ].rate )
);
adjust( i ); // check for adjustment
}
}
return true;
} else {
return false;
}
}
The amount of OHM to be minted from the treasury to the staking contract is controlled by the rate
set by the policy team. It is a percentage of total OHM supply. This calculated reward amount cannot exceed the treasury’s excess reserves as each OHM in existence has to be backed by treasury assets. This require
check is the V1 version of the infamous line 577 meme ( can’t find the original comment :( ).
function mintRewards( address _recipient, uint _amount ) external {
require( isRewardManager[ msg.sender ], "Not approved" );
require( _amount <= excessReserves(), "Insufficient reserves" );
IERC20Mintable( OHM ).mint( _recipient, _amount );
emit RewardsMinted( msg.sender, _recipient, _amount );
}
After each distribution, a rate adjustment is performed (very similar to how the bond control variable is adjusted after a bond purchase). The reward rate can go or down in order to hit a target, depending on how the policy team sets it.
The treasury
There are a few use of treasury I want to talk about besides the ones mentioned already.
Approved depositors can deposit assets directly into the treasury to mint OHM without vesting. This is needed to acquire the initial supply of OHM to create a liquidity pool.
function deposit( uint _amount, address _token, uint _profit ) external returns ( uint send_ ) {
require( isReserveToken[ _token ] || isLiquidityToken[ _token ], "Not accepted" );
IERC20( _token ).safeTransferFrom( msg.sender, address(this), _amount );
if ( isReserveToken[ _token ] ) {
require( isReserveDepositor[ msg.sender ], "Not approved" );
} else {
require( isLiquidityDepositor[ msg.sender ], "Not approved" );
}
uint value = valueOf(_token, _amount);
// mint OHM needed and store amount of rewards for distribution
send_ = value.sub( _profit );
IERC20Mintable( OHM ).mint( msg.sender, send_ );
totalReserves = totalReserves.add( value );
emit ReservesUpdated( totalReserves );
emit Deposit( _token, _amount, value );
}
Approved reserve spenders can withdraw reserve assets from the treasury by burning OHM. While I don’t think Olympus will wind down, a number of OHM forks have liquidated and I believe this is the function for the team to call to burn their tokens for the underlying reserve assets to be distributed to token holders.
function withdraw( uint _amount, address _token ) external {
require( isReserveToken[ _token ], "Not accepted" ); // Only reserves can be used for redemptions
require( isReserveSpender[ msg.sender ] == true, "Not approved" );
uint value = valueOf( _token, _amount );
IOHMERC20( OHM ).burnFrom( msg.sender, value );
totalReserves = totalReserves.sub( value );
emit ReservesUpdated( totalReserves );
IERC20( _token ).safeTransfer( msg.sender, _amount );
emit Withdrawal( _token, _amount, value );
}
Approved addresses (yield strategies) can manage the treasury’s excess reserves by calling the function
manage
to move treasury assets to its own contract for deployment.
function manage( address _token, uint _amount ) external {
if( isLiquidityToken[ _token ] ) {
require( isLiquidityManager[ msg.sender ], "Not approved" );
} else {
require( isReserveManager[ msg.sender ], "Not approved" );
}
uint value = valueOf(_token, _amount);
require( value <= excessReserves(), "Insufficient reserves" );
totalReserves = totalReserves.sub( value );
emit ReservesUpdated( totalReserves );
IERC20( _token ).safeTransfer( msg.sender, _amount );
emit ReservesManaged( _token, _amount );
}
Approved addresses can borrow excess treasury reserves by calling
incurDebt
(In reality, I am not sure how different this function is frommanage
, they both transfer assets to the approved address except a debtor has to repay its debt, but it also doesn’t really have to repay its debt if it doesn’t want to? Maybe one of you have an answer?).
My deployment code
Olympus’s codebase comes with a deployment script, but I thought that in order to understand a protocol, I should do it by myself one by one to really make sure I know what I am doing.
I decided to go with hardhat-deploy
as this is an easy-to-use library to manage deployments and it allows me to better separate deployment tasks into steps. The code is here (Sorry that it is still a pull request, but it works and you can pull the branch to your machine regardless) and you can see each step in the deploy
folder. After the contracts are deployed, there are a number of post-deployment steps you have to run in order for the contracts to be connected to each other and for the protocol to be functional. They are located in the folder scripts/post_deployment
. For liquidity bonds, I created a separate folder scripts/brick_frax_pair_bond_deployment
(BRICK was going to be our version of OHM and FRAX would be our DAI). It creates a Uniswap V2 pair on the testnet, adds liquidity and deploys a liquidity bond depository.
After completing the deployment, you can try running scripts/bonding_test.js
and scripts/staking_test.js
as a sanity check to make sure you are able to purchase a bond as well as to stake your OHM.
Take it for a spin and see whether you can run your OHM fork locally!
https://ethereum.stackexchange.com/questions/113360/what-are-the-theories-and-methods-of-calculating-the-risk-free-value-of-lp-token