Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: geometric tick spacing with additive ranges #3874

Merged
merged 68 commits into from
Jan 12, 2023
Merged

Conversation

czarcas7ic
Copy link
Member

@czarcas7ic czarcas7ic commented Dec 28, 2022

Geometric Tick Spacing with Additive Ranges ADR

Context

In Uniswap V3, discrete points (called ticks) are used when providing liquidity in a concentrated liquidity pool. The price [p] corresponding to a tick [t] is defined by the equation:

$$ p(i) = 1.0001^t $$

This results in a .01% difference between adjacent tick prices. However, this does not allow for control over the specific prices that the ticks correspond to. For example, if a user wants to make a limit order at the $17,100.50 price point, they would have to interact with either tick 97473 (corresponding to price $17,099.60) or tick 97474 (price $17101.30).

Since we know what range a pair will generally trade in, how do we go about providing more granularity at that range and provide a more optimal price range between ticks instead of the "one-size-fits-all" approach explained above?

Decision

In Osmosis' implementation of concentrated liquidity, we will instead make use of geometric tick spacing with additive ranges.

We start by defining a precision factor at a spot price of one ($k_{p1}$).

For instance, if $k_{p1} = -4$ , then each tick starting at 1 and ending at the first factor of 10 will represents a spot price increase of 0.0001. At this precision factor:

  • $tick_0 = 1$ (tick 0 is always equal to 1 regardless of precision factor)
  • $tick_1 = 1.0001$
  • $tick_2 = 1.0002$
  • $tick_3 = 1.0003$

This continues on until we reach a spot price of 10. At this point, since we have increased by a factor of 10, our $k_{current}$ increases from -4 to -3, and the ticks will increase as follows:

  • $tick_{90000} = 10$
  • $tick_{90001} = 10.001$
  • $tick_{90002} = 10.002$

For spot prices less than a dollar, the precision factor decreases at every factor of 10. For example, with a $k_{p1}$ of -4:

  • $tick_{-1} = 0.9999$
  • $tick_{-2} = 0.9998$
  • $tick_{-5001} = 0.4999$
  • $tick_{-5002} = 0.4998$

With a $k_{p1}$ of -6:

  • $tick_{-1} = 0.999999$
  • $tick_{-2} = 0.999998$
  • $tick_{-5001} = 0.994999$
  • $tick_{-5002} = 0.994998$

Formulas

After we define $k_{p1}$ (this is chosen by the user based on what precision they desire the asset pair to trade at), we can then calculate how many ticks must be crossed in order for k to be incremented ($kIncrementDistance$):

$$ kIncrementDistance = 9 * 10^{(-k_{p1})} $$

Now that we know how many ticks must be crossed in order for our k to be incremented, we can then figure out what our change in k will be based on what tick we are trading at:

$$ kΔ = ⌊ tick / kIncrementDistance ⌋ $$

With $kΔ$ and $k_{p1}$, we can figure out what the k value we will be at when we reach the provided tick:

$$ k_{current} = k_{p1} + kΔ $$

Knowing what our $k_{current}$ is, we must then figure out what power of 10 this k corresponds to:

$$ currentIncrement = 10^{(k_{current})} $$

Lastly, we must determine how many ticks above the current increment we are at:

$$ numAdditiveTicks = tick - (kΔ * kIncrementDistance) $$

With this, we can determine the price:

$$ price = (10^{kΔ}) + (numAdditiveTicks * currentIncrement) $$

Example

Bob sets a limit order on the USD<>BTC pool at tick 36650010. This pool's $k_{p1}$ is -6. What price did Bob set his limit order at?

$$ kIncrementDistance = 9 * 10^{(6)} = 9000000$$

$$ kΔ = ⌊ 36650010 / 9000000 ⌋ = 4$$

$$ k_{current} = -6 + 4 = -2$$

$$ currentIncrement = 10^{(-2)} = 0.01$$

$$ numAdditiveTicks = 36650010 - (4 * 9000000) = 650010$$

$$ price = (10^{4}) + (650010 * 0.01) = $16,500.10$$

Bob set his limit order at price $16,500.10

Consequences

This decision allows us to define ticks at spot prices that users actually desire to trade on, rather than arbitrarily defining ticks at .01% distance between each other. This will also make integration with UX seamless, instead of either

a) Preventing trade at a desirable spot price or
b) Having the front end round the tick's actual price to the nearest human readable/desirable spot price

One draw back of this implementation is the requirement to create many ticks that will likely never be used. For example, in order to create ticks at 10 cent increments for spot prices greater than $10000, a $k_{p1}$ value of -5 must be set, requiring us to traverse ticks 1-3600000 before reaching $10,000. This should simply be an inconvenience and should not present any valid DOS vector for the chain.

@czarcas7ic czarcas7ic marked this pull request as ready for review December 29, 2022 19:17
@czarcas7ic czarcas7ic requested a review from a team December 29, 2022 19:17
@czarcas7ic czarcas7ic changed the base branch from concentrated-liquidity-main to main December 30, 2022 05:12
@czarcas7ic czarcas7ic added the V:state/breaking State machine breaking PR label Dec 30, 2022
@@ -158,7 +158,7 @@ func (suite *ConcentratedMathTestSuite) TestCalcAmount0Delta() {
liquidity: sdk.MustNewDecFromStr("1517882343.751510418088349649"), // we use the smaller liquidity between liq0 and liq1
sqrtPCurrent: sdk.MustNewDecFromStr("70.710678118654752440"), // 5000
sqrtPUpper: sdk.MustNewDecFromStr("74.161984870956629487"), // 5500
amount0Expected: "998976.618347426747968399", // TODO: should be 998976.618347426388356630
amount0Expected: "998976.618347426388356620",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why doesn't the wolfram result below match this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are off from multiplying and then dividing the sdk.Dec. I can fix this if we want to transform the sqrtPriceA and sqrtPriceB into BigDecs when calculating 0 delta. What do you think?

Copy link
Member

@p0mvn p0mvn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work. Reviewed logic so far but not tests.

Could you please also commit the ADR into the architecture.md?

x/concentrated-liquidity/internal/math/tick.go Outdated Show resolved Hide resolved
x/concentrated-liquidity/internal/math/tick.go Outdated Show resolved Hide resolved
x/concentrated-liquidity/internal/math/tick.go Outdated Show resolved Hide resolved
x/concentrated-liquidity/internal/math/tick.go Outdated Show resolved Hide resolved
x/concentrated-liquidity/internal/math/tick.go Outdated Show resolved Hide resolved
x/concentrated-liquidity/internal/math/tick.go Outdated Show resolved Hide resolved
x/concentrated-liquidity/internal/math/tick.go Outdated Show resolved Hide resolved
x/concentrated-liquidity/types/constants.go Outdated Show resolved Hide resolved
x/concentrated-liquidity/model/codec.go Outdated Show resolved Hide resolved
Sender: sender.String(),
Denom0: denom0,
Denom1: denom1,
TickSpacing: tickSpacing,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the recason for having TickSpacing by the user? Isn't it determined by PrecisionFactorAtPriceOne dynamically?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I think we need to find some different verbiage to explain this since its really easy to get confused. This tick spacing is referring to the ticks that are authorized to be initialized, i.e if this number is 5, then only ticks 0, 5, 10 etc can be initialized. The tick spacing you are referring to is the prices that each and every tick reference, which changes based on k value

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With our new model for the prices that each and every tick reference that are based on k value, what is the use case for restricting people to provide liquidity at any tick possible given this new model?

In other words, why do we still want to restrict authorized ticks?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TickSpacing does not seem to be sued anywhere other than validation in withdrawPosition and createPosition

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it actually serves an important use case! Let me give an example:

Imagine we have a BTC<>USD pool trading in the $XXXX.XX range, and we want to trade at 10 cent ticks. Instead of launching the pool with with a kAtPriceOne value of -5, we can instead launch a BTC<>USD pool with tick spacing of 10 and a kAtPriceOne value of -6. This way, it gives us maneuver space if BTC were to go to $100,000. Instead of having to create a whole new pool, we can just change the tick spacing value to 1!

The problem with this is we cant easily bump this back up to 10 once we go to 1, so if we went from $100,000 to say $90,000 we would have to make a new pool.

The way I forsee it, if an asset were to have some crazy pump this is just a "fail-safe" for a lack of a better term that we can trigger while we decide if we want to make a new pool.

Please lmk your thoughts!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In your example, who would have the right to update tick spacing? Is the goal to make it a governance-changeable parameter?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah feels like gov param would make the most sense

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm normally protocol governance shouldn't be voting on individual pool parameters, but I can see the fallback scenario argument

As long as we limit to only being able to reduce tick spacing (to prevent a pool from being gov attacked) I don't see any security concerns, but it's still not 100% clear to me that this setup is a clean design

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I am not convinced either way yet, but its better to have the logic there and take it away instead of having to reimplement it later imo

@czarcas7ic czarcas7ic changed the title feat: additive ticks feat: geometric tick spacing with additive ranges Jan 2, 2023
Copy link
Member

@p0mvn p0mvn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this round, I focused on reviewing tick.go and tick_test.go`.

TickToPrice LGTM.

Wondering if we can simplify and speed up PriceToTick but don't have a specific suggestion yet, only some ideas

x/concentrated-liquidity/internal/math/tick.go Outdated Show resolved Hide resolved
x/concentrated-liquidity/internal/math/tick.go Outdated Show resolved Hide resolved
x/concentrated-liquidity/internal/math/tick.go Outdated Show resolved Hide resolved
x/concentrated-liquidity/internal/math/tick.go Outdated Show resolved Hide resolved
x/concentrated-liquidity/internal/math/tick.go Outdated Show resolved Hide resolved
// Now, we loop through the k increments until we have passed the price
// Once we pass the price, we can determine what which k values we have filled in their entirety,
// as well as how many ticks that corresponds to
for total.LT(price) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: what is the runtime of this loop? Is it a concern?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have no solid understanding of the time complexity here. This function seems to be called every swap step. Each swap step, we start searching from the price of 1 until the actual price. This seems like O(k * n) time complexity where k is the number of swap steps and n is the price difference from 1.

I think this could be optimized. For every new swap step we are unlikely to drift too far away from the previous price. As a result, we can start searching from the old price, not from 1.

I'm not sure how worthwhile it is of an improvement. But the potential O(k * n) seems concerning. Would like to know what other reviewers think

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another idea is to binary search this

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another idea is to take a log base 10 of price via osmomath and truncate it. This will give us a geometric part of the ticks or kDelta. Then, we can binary search the additive range (maybe there is even an easier way to compute it)

Again, not sure if I'm overthinking the performance implications here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should only loop through this a maximum of the number of k values there are. So for instance, with a maximum of a k value of -12 and min of -1, we should only loop through this 11 times.

Agreed there is probably a better way to do this though, down to try these suggestions. Do you have any good recommendation on benchmarking?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have any good recommendation on benchmarking?

I think we can try creating a benchmark around calcOutAmtGivenIn, initialize a pool close to max spot price, initialize many positions and try executing a large swap where kAtPriceOne is the smallest one possible. I think this would help us benchmark the worst-case scenario. Such a benchmark would be useful even outside of the context of PriceToTick

I don't think it's worth blocking this PR on this at this moment. If you don't a relatively quick and trivial way to simplify this, we might want to capture this thread in an issue and punt it until later

x/concentrated-liquidity/internal/math/tick.go Outdated Show resolved Hide resolved
x/concentrated-liquidity/internal/math/tick_test.go Outdated Show resolved Hide resolved
@p0mvn
Copy link
Member

p0mvn commented Jan 3, 2023

Do you mind committing the ADR / the doc for this into the architecture.md in this PR?

I think it would be useful to review it directly with the changes.

Copy link
Member

@p0mvn p0mvn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • TickToPrice
  • TestTickToPrice
  • PriceToTick
  • TestPriceToTick

All LGTM after comments from this round are addressed.

Going to focus on swaps and swap tests in the next review cycle

app/apptesting/concentrated_liquidity.go Outdated Show resolved Hide resolved
x/concentrated-liquidity/architecture.md Show resolved Hide resolved
x/concentrated-liquidity/architecture.md Outdated Show resolved Hide resolved
x/concentrated-liquidity/architecture.md Show resolved Hide resolved
x/concentrated-liquidity/architecture.md Outdated Show resolved Hide resolved
Comment on lines 170 to 171
minTick = sdk.NewDec(18).Mul(geometricExponentIncrementDistanceInTicks).Neg().RoundInt().Int64()
maxTick = sdk.NewDec(38).Mul(geometricExponentIncrementDistanceInTicks).RoundInt().Int64()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's define these decs as file-global variables to avoid reallocations.

Also, are you sure we want to use banker's rounding here and not truncate for maxTick and round up for min tick?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These will always be whole numbers, but yes I can do that anyway

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought that file-global vars only make sense if a number is used more than once?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm suggesting the file-global vars here to reduce these allocations:
https://github.com/osmosis-labs/cosmos-sdk/blob/48fe175f983925ff6202d492a363d2b2e3a62497/types/decimal.go#L94

If we use NewDec directly in the function, every time the function is called, Go will re-allocate the underlying memory which takes away resources. While this overhead is small, it is still good to address if the function is low-level and called frequently

tickExpected: "4030301",
},
"max spot price and minimum exponentAtPriceOne": {
price: MaxSpotPrice,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add tests with MaxSpotPrice + 1, MinSpotPrice and MinSpotPrice - 1 to test the edge cases please

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added MaxSpotPrice+1 and MinSpotPrice 00c430b

Couldn't add MinSpotPrice - 1 since that is already checked by "price must be positive"

"error: price must be positive": {
price: sdk.NewDec(-1),
exponentAtPriceOne: sdk.NewInt(-6),
expectedError: fmt.Errorf("price must be greater than zero"),
},

This is because min spot price is the minimum a sdk.Dec could possibly be, and any smaller it would be zero

exponentAtPriceOne: sdk.NewInt(-6),
expectedError: fmt.Errorf("price must be greater than zero"),
},
"error: resulting tickIndex too large": {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also add "error: resulting tickIndex too small"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just added a check to check price is within the allowed spot price bounds before this check. I do not know how to trigger either tickIndex too large or tickIndex too small error, and it feels like it is just a defense in depth at this point

// Calculate the exponentAtCurrentTick from the starting exponentAtPriceOne and the geometricExponentDelta
exponentAtCurrentTick := exponentAtPriceOne.Add(geometricExponentDelta)
if tickIndex.IsNegative() {
// We must decrement the exponentAtCurrentTick when traversing negative ticks in order to constantly step up in precision when going further down in ticks
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean by traversal here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated the comment

Copy link
Member

@p0mvn p0mvn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome work!

LGTM once comments are addressed. Let's not block on suggestions as long as we track them in issues

@@ -127,6 +133,11 @@ func (p Pool) GetTickSpacing() uint64 {
return p.TickSpacing
}

// GetPrecisionFactorAtPriceOne returns the precision factor at price one of the pool
func (p Pool) GetPrecisionFactorAtPriceOne() sdk.Int {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: unsure what the value of this getter is when the field is exported anyways

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without this getter, how would you retrieve it from a ConcentratedPoolExtension pool object? Here is an example:

nextPrice, err := math.TickToPrice(nextTick, p.GetPrecisionFactorAtPriceOne())
if err != nil {
return sdk.Coin{}, sdk.Coin{}, sdk.Int{}, sdk.Dec{}, sdk.Dec{}, fmt.Errorf("could not convert next tick (%v) to nextPrice", nextTick)
}

x/concentrated-liquidity/model/pool.go Outdated Show resolved Hide resolved
@@ -39,29 +39,29 @@ func (suite *StrategyTestSuite) TestComputeSwapState() {
}{
"happy path: trade asset0 for asset1": {
sqrtPCurrent: sdk.MustNewDecFromStr("70.710678118654752440"), // 5000
nextSqrtPrice: sdk.MustNewDecFromStr("70.666662070529219856"), // 4993.777128190373086350
liquidity: sdk.MustNewDecFromStr("1517818840.967515822610790519"),
nextSqrtPrice: sdk.MustNewDecFromStr("70.666662070529219856"), // 4993.7771281903730
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused why we are changing these. This test does not seem to be touched by the tick logic.

What is the reason? If unrelated, can this be submitted separately?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DefaultLiquidityAmt changed when we changed tick logic

DefaultLiquidityAmt = sdk.MustNewDecFromStr("1517882343.751510418088349649")
DefaultTickSpacing = uint64(1)

}

// transform to sqrtPrice
nextSqrtPrice, err := nextPrice.ApproxSqrt()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed with earlier comment about bringing back TickToSqrtPrice function

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

x/concentrated-liquidity/swaps_test.go Outdated Show resolved Hide resolved
// expectedTokenOut: 5000000000.000 rounded down https://www.wolframalpha.com/input?key=&i=1517818840.967415409394235163+*+%2870.710678118654752440-+67.416477345120317059%29
// expectedTick: 84222.0 rounded down https://www.wolframalpha.com/input?i2d=true&i=Log%5B1.0001%2C4544.98141762512095360%5D
// expectedTokenIn: 1048861.292545921016650926872369 rounded up https://-www.wolframalpha.com/input?i=%281517882343.751510418088349649+*+%2870.710678118654752440+-+67.416615162732695594%29%29+%2F+%2867.416615162732695594+*+70.710678118654752440%29
// expectedTokenOut: 5000000000.00000000000000 rounded down https://www.wolframalpha.com/input?i=1517882343.751510418088349649+*+%2870.710678118654752440-+67.416615162732695594%29
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this value is off but can be addressed here: #3962

x/concentrated-liquidity/swaps_test.go Outdated Show resolved Hide resolved
@@ -534,7 +504,8 @@ func (s *KeeperTestSuite) TestCalcAndSwapOutAmtGivenIn() {
test.addPositions(s.Ctx, pool.GetId())

// perform calc
tokenIn, tokenOut, updatedTick, updatedLiquidity, updatedSqrtPrice, err := s.App.ConcentratedLiquidityKeeper.CalcOutAmtGivenInInternal(
// TODO: Add sqrtPrice check
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make an issue for this please

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

@ValarDragon ValarDragon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, all comments I have can be done in later revisions

@czarcas7ic
Copy link
Member Author

Thanks everyone for the super thorough reviews! Will merge post chain dev meeting tomorrow if there is consensus on doing so!

@p0mvn
Copy link
Member

p0mvn commented Jan 12, 2023

Discussed offline - going to merge to allow us to continue working on fees off of main. All comments are addressed/tracked in issues. Great work @czarcas7ic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
C:app-wiring Changes to the app folder C:docs Improvements or additions to documentation C:x/concentrated-liquidity C:x/poolmanager C:x/swaprouter V:state/breaking State machine breaking PR
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

5 participants