The incentives program is a core feature of Osmosis. Everyday, approximately 547,945.20 Osmos are distributed among Liquidity Providers (LPers) Stakers, Developers, and the Osmosis Community Pool. Forty-five percent of the 547,945.20 Osmos, about 246,575.34 Osmos go to LPers. Of those 246,575.34 Osmos, 70% are sent to the Community Pool. Working with the Osmosis Grants Program, the OGP, and ChaosLabs, Hathor Nodes has analyzed the current incentives program and implemented a new process to calculate LP incentives. This document will walk through the analysis done by Hathor Nodes and outline recommendations regarding a variety of topics. A separate summary of the final results can be found here.
Ultimately, the community can discuss and decide which recommendations to take and if there’s any further research they’d like to see done. For the sake of accessibility, this document is written with a broad audience in mind. That being said, if you read this report and have some questions, please feel free to ask me!
For the sake of reproducibility, queries, formulas, and example code will be provided along the way. Unless shown otherwise, queries are made using Flipside Crypto’s SDK and data. You can read and learn about their data here. In future phases of this work, Hathor Nodes will work w/ Chaos Labs to make live versions of key data sets publicly available.
As the numbers are constantly changing, values referenced in the document may not match the code outputs.
This section contains code used to generate results and queries used in the research paper. Non-technical readers can disregard this section.
import json
import math
from datetime import datetime
from typing import Any, Collection, Sequence, Mapping, Text
import numpy as np
import pandas as pd
import plotly
import plotly.express as px
import plotly.graph_objects as go
import requests
import scipy
from google.protobuf.json_format import MessageToDict
from plotly.subplots import make_subplots
from shroomdk import ShroomDK
from aws_client import get_secret_value
# Set up SDK, node rest API, and formatting options.
sdk = ShroomDK(get_secret_value('shroomdk_api_key')['shroomdk_api_key'])
OSMO_LCD_ENDPOINT = 'https://osmosis-mainnet-rpc.allthatnode.com:1317'
pd.options.display.float_format = '{:,}'.format
plotly.graph_objects.Layout(font={'family': 'Roboto'})
# Helper functions
def get_incentivized_pools(
lcd_endpoint: Text = OSMO_LCD_ENDPOINT) -> Collection[int]:
"""Get list of pools w/ internal incentives."""
incentivized_gauges = requests.get(
f'{lcd_endpoint}/osmosis/pool-incentives/v1beta1/incentivized_pools',
timeout=60).json()['incentivized_pools']
return {int(gauge['pool_id']) for gauge in incentivized_gauges}
incentivized_pools = get_incentivized_pools()
To optimize LP incentives, let us first review the purpose LPs have and the purpose of providing incentives. Liquidity Pools offer end-users the ability to exchange tokens at a predefined rate. At the time of writing Osmosis only incentivizes Balancer Pools. Through 2023, it is expected that Stableswap and Concentrated Liquidity LPs will be incentivized. To account for that, the Incentives Program will be designed such that its criteria and metrics are extendable to different LP types.
The primary factor on User Experience, UX, when using an exchange is the slippage they pay to make trades. Users wish to maximize the value of their trades, so they prefer exchanges w/ lower slippage & fees. Minimizing slippage requires deep liquidity in Balancer LPs. Liquidity comes from LPers who believe the swap fees and incentives they receive will provide a positive ROI compared to alternative investments.
LPers are compensated in the form of swap fees. Swap fees are predefined at each LPs creation, though they can be modified afterwards. The vast majority of incentivized LPs have swap fees set at 0.2% of the traded token.
If LPers already receive swap fees, why do they need additional incentives? The issue with relying on just swap fees is two-fold. First, when an LP is first created, it will have fairly low liquidity. This in turns causes high slippage for most users which discourages the LPs usage. Without volume, swap fees are low, which discourages providing liquidity. Second, even if an LP has sufficient initial liquidity to draw volume, there isn’t a one to one correlation between volume and required liquidity. This is because slippage is based on the size of trades relative to the liquidity in LPs.
As an example, imagine a USDC/OSMO LP in two different scenarios. In scenario A, a single swap occurs where a user trades 1,000,000 USDC for their Osmo equivalent. In scenario B, a million swaps occur over the course of a day. In each swap, users exchange 1 USDC for its Osmo equivalent. For scenario A’s LP to provide the same slippage to users as scenario B’s LP, it would significantly higher liquidity. Both LPs have the same volume and thus swap fees generated, but they have different liquidity needs.
This is where incentives come in. Incentives allow the protocol to draw liquidity to LPs whose swap fees are not high enough to sustain required liquidity levels. Incentives come at a cost though.
There are two limitations w/ using Osmo to incentivize the LP program.
One, Osmo incentives come from an inflationary process. Every day, new Osmos are minted and distributed. Excessive incentive emission can reduce the purchasing power of an Osmo. This would discourage investors from providing liquidity and increase the required Osmo emissions necessary to maintain liquidity levels.
Two, the Osmo is a fixed supply coin. Eventually, there will be no more newly minted Osmos and further incentives would have to come from alternative sources. Whether that’s external incentives from other protocols, or from the protocol’s community pool.
Thus the best course of action is to minimize the number of Osmos given to LPers. This allows us to frame the incentives program as an optimization problem, with an analytical solution.
We can now frame the incentives program as an optimization problem. Typically, optimization problems have a single objective function, which is a number we wish to either maximize or minimize. Then, constraints are defined. Constraints can be thought of as requirements that we must adhere to. In our case the number of Osmos given to LPers is our objective function, and its constraints can be defined by liquidity levels required for each individual LP to meet slippage requirements.
For each LP, minimize $ Incentives(LP) $, which is the number of Osmos given to LPers in a given LP.
Constraints can be formulated as the statement:
For a swap of size i, the user will experience no more than S slippage.
This statement allows us to define slippage expectations at varying swap sizes. It also allows us to tie slippage back to liquidity, as a key factor of slippage in a trade is the relative size of the trade to the LP’s liquidity.
With a high level framework for the problem, we can investigate the varying pieces of the puzzle until a solution is found. In the following sections of this paper, we will dive into concrete numbers for these constraints, related tokenomic concerns, the question of which LPs to incentivize, and an example solution for this problem – i.e. a set of target liquidity numbers for Osmosis LPs.
Recall our generic constraint statement: For a swap of size i, the user will experience no more than S slippage. To set constraints, we must identify a reasonable slippage level for users. As swaps occur in a variety of sizes, multiple constraints may be needed to accommodate retail and whale traders.
Before we can assess slippage expectations, we need to flesh out the relationship between swap size, liquidity, and slippage. Using Balancer Pools as an example, we will demonstrate this relationship. Slippage in a Balancer pool can be estimated w/ the following equation:
Let S = Slippage in a trade.
Let x = Number of tokens in the LP.
Let d = Number of tokens being traded.
Note that x and d are the same token. I.e. when trading ATOMs for OSMOs in LP #1, we look at the number of ATOMs being swapped and the number of ATOMs in the LP.
$$ S = 1 - (\frac{x}{x+d})^2 $$This formula does not account for swap fees. Swap fees are typically taken out prior to a trade being executed, so we can account them as so:
Let f = Swap fees paid for the transaction.
$$ S = \frac{1 - (\frac{x}{x+d})^2}{1 - f} $$This equation is defines slippage as a function of liquidity, swap fees, and swap size. For our use case, we'd like an equation that defines (healthy) liquidity based on a given slippage, fees, and swap size. Let's rearrange the equation:
$$ S = \frac{1 - (\frac{x}{x+d})^2}{1 - f} $$$$ (1-f)S = 1 - (\frac{x}{x + d})^2 $$$$ (1-f)S - 1 = -(\frac{x}{x + d})^2 $$$$ 1 - (1-f)S = (\frac{x}{x + d})^2 $$$$ \sqrt{1 - (1 - f)S} = \frac{x}{x + d} $$$$ x = (x+d)\sqrt{1 - (1 - f)S} $$$$ x = x\sqrt{1 - (1 - f)S} + d\sqrt{1 - (1 - f)S} $$$$ x - x\sqrt{1 - (1 - f)S} = d\sqrt{1 - (1 - f)S} $$$$ x(1 -\sqrt{1 - (1 - f)S}) = d\sqrt{1 - (1 - f)S} $$$$ x = d \frac{\sqrt{1 - (1-f)S}}{1 - \sqrt{1 - (1-f)S}} $$We can simplify this equation a bit defining a swap multiplier function, $ M(f, S) $, which contains the constant by d.
$$ M(f, S) = \frac{\sqrt{1 - (1-f)S}}{1 - \sqrt{1 - (1-f)S}} $$$$ x = M(f, S) * d $$To note that x and d are token specific values we can write them as $ x_t $ and $ d_t $.
Now we have the liquidity for a required for a single token, x, based on the LP's swap fees, f, target slippage, S, and swap size, d. To convert this into a target TVL for a single LP, we need to incorporate two more elements, the token prices, and the LP's token weights.
Let $p_t$ be the price of a token, t. Let $w_{t, L}$ be the weight of a token, t, in an LP, L.
The target TVL, in USD, of an LP is the largest target TVL for each of its tokens:
$$ TVL_L(f, S) = \max{\frac{x_t * p_t}{w_{t, L}}} \forall t \in L $$Since we are looking at swap sizes and know the relationship between $ x_t $ and $ d_t $, we can change this to:
$$ TVL_L(f, S) = \max{\frac{M(f, S) * d_t * p_t}{w_{t, L}}} \forall t \in L $$We multiply by $ p_t $ to convert the token counts to USD. Instead of comparing counts of two tokens, we can compare their USD value and take the max.
Dividing by $ w_{t, L} $ allows us to calculate the TVL of the LP that maps back to the token's required TVL. Note that for LPs like the Ion/Osmo LP, 20:80 weights, this causes higher TVL levels than a 50:50 LP. If you need 500k USD of Ion in that LP, then the entire LP must have a TVL of 2500k USD. In a 50:50 LP, you need 1000k USD. Similarly, an LP w/ four tokens of even 25:25:25:25 weights would tend to require higher TVL levels than a 50:50 two token LP. This is something to keep in mind when deciding to incentivize LPs w/ non 50:50 weights.
With an equation at hand, let's create a helper function calculating the swap multipliers and then set forth w/ identifying target TVL levels.
def calc_balancer_swap_multiplier(swap_fee: float, slippage: float) -> float:
"""Calculate the swap multiplier required to achieve target slippage.
Formula is specifically for a balancer pool."""
return ((math.sqrt(1 - ((1 - swap_fee) * slippage))
/ (1 - math.sqrt(1 - ((1 - swap_fee) * slippage))))
/ (1 - swap_fee))
The vast majority of users trade on the Osmosis Frontend. The frontend allows users to input swaps and creates a transaction for them to sign. An example swap is shown below:
The user selects a token to trade and an exact amount of that token to trade. In this case, our user wishes to trade a million Osmos. They also select the token they wish to receive in return, Atoms in our example. The App creates a swap transaction by identifying a route, i.e. which LPs to use, and a minimum # of Atoms the user will receive in return. The exact number of Atoms our user will receive can vary depending on the ratio of Osmos/Atoms at the time of execution, but the transaction will fail if the user cannot receive the specified minimum amount.
The important thing to note here is that the App displays and accounts for 1% slippage. The swap fees in this LP are 0.2%, but the transaction is set up to cap user slippage at 1%. Technically, you can still face larger than 1% slippage, but that is noted to the user as the price impact.
Recommendation: As User Experience is key to the success of any app, Hathor Nodes recommends ensuring the vast majority of users experience no more than 1% slippage to align with the front end. For users swapping significantly large numbers of tokens, i.e. whales, a higher slippage expectation can be set.
Pros:
Cons:
To address the concerns above, let us take a look at swaps made by users.
Expanding further on large swaps, let’s query the USD value of the largest swap made in each LP in the last ninety days.
query = """
WITH last_recorded_price AS (
SELECT
CURRENCY,
SYMBOL,
MAX(recorded_hour) AS recorded_hour
FROM
osmosis.core.ez_prices
GROUP BY
CURRENCY,
SYMBOL)
SELECT
CAST(POOL_IDS[0] AS INT) AS pool_id,
ROUND(MAX(price * FROM_AMOUNT / POW(10, FROM_DECIMAL)), 2) AS max_swap_usd
FROM
osmosis.core.fact_swaps swaps
INNER JOIN
last_recorded_price
ON swaps.from_currency = last_recorded_price.currency
INNER JOIN
osmosis.core.ez_prices prices USING(symbol, recorded_hour)
WHERE
BLOCK_TIMESTAMP >= DATEADD(day, -90, CAST(GETDATE() AS DATE))
GROUP BY
POOL_IDS[0]
ORDER BY
max_swap_usd DESC,
pool_id
LIMIT
10;
"""
max_swaps = pd.DataFrame(sdk.query(query).records).set_index('pool_id')
max_swaps
max_swap_usd | |
---|---|
pool_id | |
1 | 1,099,199.2 |
808 | 580,410.06 |
704 | 408,419.38 |
678 | 377,930.58 |
712 | 199,785.15 |
9 | 180,992.11 |
674 | 129,164.2 |
803 | 81,881.13 |
497 | 73,449.9 |
498 | 50,982.4 |
Given that most LPs have a swap fee of 0.2%, and we are aiming for a Slippage of 1%, lets see what healthy TVL levels would look like if we wanted to accommodate the max_swap sizes:
print(
f'The swap multiplier is approximately '
f'{round(calc_balancer_swap_multiplier(0.002, 0.01))}.')
The swap multiplier is approximately 199.
Our swap multiplier is roughly equal to 199.00. For the example set of LPs queried above, the weights can be assumed to be 0.5 in most cases. Also, our query has already selected the max value for us. Plugging in these values and simplifying a bit, we get:
$$ TVL_L(f, S) = \max{\frac{M(f, S) * d_t * p_t}{w_{t, L}}} \forall t \in L $$$$ TVL_L(0.002, 0.01) = \frac{199.0 * d_t * p_t}{0.5} = 398 * d_t * p_t $$Where $ d_t * p_t $ is the value found in max_swap_usd.
Looking at a set of LPs w/ 50:50 weights, lets compare target and current TVLs if we attempted to provide 1% Slippage to all LPs.
def get_current_tvl(pool_id: int) -> float:
liquidity = requests.get(
f'https://api-osmosis.imperator.co/pools/v2/{pool_id}',
timeout=60).json()[0]['liquidity']
return round(liquidity, 2)
max_swaps['target_tvl'] = round(398 * max_swaps.max_swap_usd, 2)
max_swaps['current_tvl'] = max_swaps.index.map(
lambda pool_id: get_current_tvl(pool_id))
max_swaps
max_swap_usd | target_tvl | current_tvl | |
---|---|---|---|
pool_id | |||
1 | 1,099,199.2 | 437,481,281.6 | 67,207,421.69 |
808 | 580,410.06 | 231,003,203.88 | 521.68 |
704 | 408,419.38 | 162,550,913.24 | 11,491,213.02 |
678 | 377,930.58 | 150,416,370.84 | 25,345,740.67 |
712 | 199,785.15 | 79,514,489.7 | 12,452,091.19 |
9 | 180,992.11 | 72,034,859.78 | 3,363,235.22 |
674 | 129,164.2 | 51,407,351.6 | 4,903,556.51 |
803 | 81,881.13 | 32,588,689.74 | 12,337,473.69 |
497 | 73,449.9 | 29,233,060.2 | 3,440,666.66 |
498 | 50,982.4 | 20,290,995.2 | 1,596,823.4 |
Note that these TVL levels are far above current TVL levels. In some cases by over 10 tenfold. Realistically, we can't afford to provide 1% slippage to this level of swaps. So, a different slippage constraint is needed for whales.
To create a separate liquidity constraint for whales, we must first be able to define what a whale is in the context of a given LP/token combination. We can think of whales as outliers among the dataset of swaps in an LP. The question is, can we clearly define outliers? Let us take a look at an example of swap distributions within an LP.
This example will take the 100,000 largest non-arb swaps in LP #1, in the last 90 days. Arb swaps are excluded because they tend to be small in size and numerous, further skewing our swap distribution.
def get_pool_swaps(pool_id: int) -> pd.DataFrame:
"""Distribution of tokens swapped in a pool."""
pool_info = requests.get(
f'https://api-osmosis.imperator.co/pools/v2/{pool_id}').json()
pool_denoms = tuple([token['denom'] for token in pool_info])
sdk = ShroomDK(get_secret_value('shroomdk_api_key')['shroomdk_api_key'])
query = f"""
SELECT
DATE(BLOCK_TIMESTAMP) AS swap_date,
labels.PROJECT_NAME AS token,
FROM_AMOUNT / POW(10, FROM_DECIMAL) AS amount
FROM
osmosis.core.FACT_SWAPS swaps
INNER JOIN osmosis.core.DIM_LABELS labels ON
swaps.FROM_CURRENCY = labels.ADDRESS
WHERE
FROM_CURRENCY <> TO_CURRENCY
AND FROM_CURRENCY != 'uosmo'
AND POOL_IDS[0] = '{pool_id}'
AND FROM_CURRENCY IN {pool_denoms}
AND DATE(BLOCK_TIMESTAMP) >= DATEADD(day, -90, GETDATE());
"""
return pd.DataFrame(sdk.query(query).records)
example_swaps = get_pool_swaps(1)
example_swaps
swap_date | token | amount | |
---|---|---|---|
0 | 2023-01-21 | ATOM | 62.149999 |
1 | 2023-01-21 | ATOM | 8.0 |
2 | 2023-01-21 | ATOM | 41.579999 |
3 | 2023-01-21 | ATOM | 80.0 |
4 | 2023-01-21 | ATOM | 12.859999 |
... | ... | ... | ... |
99995 | 2022-11-28 | ATOM | 100.0 |
99996 | 2022-11-28 | ATOM | 6.045972 |
99997 | 2022-11-28 | ATOM | 0.91 |
99998 | 2022-11-28 | ATOM | 0.038958 |
99999 | 2022-11-28 | ATOM | 0.144191 |
100000 rows × 3 columns
fig = px.histogram(
example_swaps, x='amount', color='token', width=900, height=600,
title=f'Distribution of Swaps in LP #1',
template='simple_white', labels={'token': 'Token'})
fig.update_layout(font_family="Robot", font_size=16, xaxis_title='Swap Amount',
yaxis_title='Number of Swaps')
fig.update_yaxes(separatethousands=True)
fig.update_xaxes(range=[0, 1000])
fig