Skip to content

Commit

Permalink
Stableswap: swap, scale rounding & swapfee alignment w/ spec (#2912)
Browse files Browse the repository at this point in the history
* spec update

* More spec conformance

Co-authored-by: alpo <62043214+AlpinYukseloglu@users.noreply.github.com>
  • Loading branch information
ValarDragon and AlpinYukseloglu authored Oct 1, 2022
1 parent 66ea292 commit f4c37ab
Show file tree
Hide file tree
Showing 4 changed files with 116 additions and 60 deletions.
80 changes: 68 additions & 12 deletions x/gamm/pool-models/stableswap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,17 @@ In the choice of curve section, we see that its the case that when `x_reserves ~
2) Relatedly, suppose theres a token called `TwoFoo` which should trade around `1 TwoFoo = 2 Foo`
3) For staking derivatives, where value accrues within the token, the expected price to concentrate around dynamically changes (very slowly).

To handle these cases, we introduce scaling factors. A scaling factor maps from "base coin units" to "amm math reserve units", by dividing.
To handle these cases, we introduce scaling factors. A scaling factor maps from "raw coin units" to "amm math units", by dividing.
To handle the first case, we would make `Foo` have a scaling factor of `10^6`, and `WrappedFoo` have a scaling factor of `1`.
Then the reserves we pass into all AMM equations for this pool, would be computed based off the following reserves:
This mapping is done via `raw coin units / scaling factor`.
We use a decimal object for amm math units, however we still have to be precise about how we round.
We introduce an enum `rounding mode` for this, with three modes: `RoundUp`, `RoundDown`, `RoundBankers`.

The reserve units we pass into all AMM equations would then be computed based off the following reserves:

```python
Foo_reserves = round(pool.Foo_liquidity / 10^6, RoundingMode)
WrappedFoo_reserves = round(pool.WrappedFoo_liquidity / 1, RoundingMode)
scaled_Foo_reserves = decimal_round(pool.Foo_liquidity / 10^6, RoundingMode)
descaled_Foo_reserves = scaled_Foo_reserves * 10^6
```

Similarly all token inputs would be scaled as such.
Expand Down Expand Up @@ -171,23 +175,75 @@ def binary_search(lowerbound, upperbound, approximation_fn, target, max_iteratio
return cur_y_guess
```

As we changed API slightly, to have this "y_0" guess, we use the following as `solve_y` pseudocode here on out:
```python
# solve_cfmm returns y_f s.t. CFMM_eq(x_f, y_f, w) = k
# for the no-v variant of CFMM_eq
def solve_y(x_0, y_0, w, in_amt):
x_f = x_0 + in_amt
k = CFMM_eq(x_0, y_0, w)
err_tolerance = {"within .0001%"} # TODO: Detail what we choose / how we reason about choice
return iterative_search(x_f, y_0, w, k, err_tolerance):
```

#### Using this in swap methods

Detail how we take the previously discussed solver, and build SwapExactAmountIn and SwapExactAmountOut.
So now we put together the components discussed in prior sections to achieve pseudocode for the SwapExactAmountIn
and SwapExactAmountOut functions.

We assume existence of a function `pool.ScaledLiquidity(input, output, rounding_mode)` that returns `in_reserve, out_reserve, rem_reserves`, where each are scaled by their respective scaling factor using the provided rounding mode.

##### SwapExactAmountIn

So now we need to put together the prior components.
When we scale liquidity, we round down, as lower reserves -> higher slippage.
Similarly when we scale the token in, we round down as well.
These both ensure no risk of over payment.

The amount of tokens that we treat as going into the "0-swap fee" pool we defined equations off of is: `amm_in = in_amt_scaled * (1 - swapfee)`. (With `swapfee * in_amt_scaled` just being added to pool liquidity)

Then we simply call `solve_y` with the input reserves, and `amm_in`.

<!-- TODO: Maybe we just use normal pseudocode syntax -->
```python
def SwapExactAmountIn(pool, in_coin, out_denom):
# Round down as lower reserves -> higher slippage
scaledReserves = pool.ScaledLiquidity(RoundingMode.RoundDown)
in_amt_scaled = pool.ScaleToken(in_coin)
in_reserve, out_reserve = scaledReserves[in_coin.Denom], scaledReserves[out_denom]
rem_reserves = { x for x in scaledReserves if (x != in_coin.Denom and x != out_denom) }
solveCfmm(out_reserve, in_reserve, remReserves, in_amt_scaled)
def CalcOutAmountGivenExactAmountIn(pool, in_coin, out_denom, swap_fee):
in_reserve, out_reserve, rem_reserves = pool.ScaledLiquidity(in_coin, out_denom, RoundingMode.RoundDown)
in_amt_scaled = pool.ScaleToken(in_coin, RoundingMode.RoundDown)
amm_in = in_amt_scaled * (1 - swap_fee)
out_amt_scaled = solve_y(in_reserve, out_reserve, remReserves, in_amt_scaled)
out_amt = pool.DescaleToken(out_amt_scaled, out_denom)
return out_amt
```

##### SwapExactAmountOut

<!-- TODO: Explain overall context of this section -->
When we scale liquidity, we round down, as lower reserves -> higher slippage.
Similarly when we scale the exact token out, we round up to increase required token in.

We model the `solve_y` call as we are doing a known change to the `out_reserve`, and solving for the implied unknown change to `in_reserve`.
To handle the swapfee, we apply the swapfee on the resultant needed input amount.
We do this by having `token_in = amm_in / (1 - swapfee)`.


<!-- TODO: Maybe we just use normal pseudocode syntax -->
```python
def CalcInAmountGivenExactAmountOut(pool, out_coin, in_denom, swap_fee):
in_reserve, out_reserve, rem_reserves = pool.ScaledLiquidity(in_denom, out_coin, RoundingMode.RoundDown)
out_amt_scaled = pool.ScaleToken(in_coin, RoundingMode.RoundUp)

amm_in_scaled = solve_y(out_reserve, in_reserve, remReserves, -out_amt_scaled)
swap_in_scaled = ceil(amm_in_scaled / (1 - swapfee))
in_amt = pool.DescaleToken(swap_in_scaled, in_denom)
return in_amt
```

We see correctness of the swap fee, by imagining what happens if we took this resultant input amount, and ran `SwapExactAmountIn (seai)`. Namely, that `seai_amm_in = amm_in * (1 - swapfee) = amm_in`, as desired!

#### Precision handling

{Something we have to be careful of is precision handling, notes on why and how we deal with it.}

### Spot Price

Spot price for an AMM pool is the derivative of its `CalculateOutAmountGivenIn` equation.
Expand Down
36 changes: 26 additions & 10 deletions x/gamm/pool-models/stableswap/amm.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,36 +250,52 @@ func (p Pool) spotPrice(baseDenom, quoteDenom string) (sdk.Dec, error) {
return bigDec.SDKDec(), nil
}

func oneMinus(swapFee sdk.Dec) osmomath.BigDec {
return osmomath.BigDecFromSDKDec(sdk.OneDec().Sub(swapFee))
}

// returns outAmt as a decimal
func (p Pool) calcOutAmtGivenIn(tokenIn sdk.Coin, tokenOutDenom string, swapFee sdk.Dec) (sdk.Dec, error) {
roundMode := osmomath.RoundDown // TODO:
reserves, err := p.scaledSortedPoolReserves(tokenIn.Denom, tokenOutDenom, roundMode)
// round liquidity down, and round token in down
reserves, err := p.scaledSortedPoolReserves(tokenIn.Denom, tokenOutDenom, osmomath.RoundDown)
if err != nil {
return sdk.Dec{}, err
}
tokenInSupply, tokenOutSupply, remReserves := reserves[0], reserves[1], reserves[2:]
tokenInDec := osmomath.BigDecFromSDKDec(tokenIn.Amount.ToDec()) // TODO: Round mode
tokenInDec, err := p.scaleCoin(tokenIn, osmomath.RoundDown)
if err != nil {
return sdk.Dec{}, err
}

// amm input = tokenIn * (1 - swap fee)
ammIn := tokenInDec.Mul(oneMinus(swapFee))
// We are solving for the amount of token out, hence x = tokenOutSupply, y = tokenInSupply
cfmmOut := solveCfmm(tokenOutSupply, tokenInSupply, remReserves, tokenInDec)
cfmmOut := solveCfmm(tokenOutSupply, tokenInSupply, remReserves, ammIn)
outAmt := p.getDescaledPoolAmt(tokenOutDenom, cfmmOut)
return outAmt.SDKDec(), nil
return outAmt, nil
}

// returns inAmt as a decimal
func (p *Pool) calcInAmtGivenOut(tokenOut sdk.Coin, tokenInDenom string, swapFee sdk.Dec) (sdk.Dec, error) {
roundMode := osmomath.RoundDown // TODO:
reserves, err := p.scaledSortedPoolReserves(tokenInDenom, tokenOut.Denom, roundMode)
// round liquidity down, and round token out up
reserves, err := p.scaledSortedPoolReserves(tokenInDenom, tokenOut.Denom, osmomath.RoundDown)
if err != nil {
return sdk.Dec{}, err
}
tokenInSupply, tokenOutSupply, remReserves := reserves[0], reserves[1], reserves[2:]
tokenOutAmount := osmomath.BigDecFromSDKDec(tokenOut.Amount.ToDec()) // TODO: round mode
tokenOutAmount, err := p.scaleCoin(tokenOut, osmomath.RoundUp)
if err != nil {
return sdk.Dec{}, err
}

// We are solving for the amount of token in, cfmm(x,y) = cfmm(x + x_in, y - y_out)
// x = tokenInSupply, y = tokenOutSupply, yIn = -tokenOutAmount
cfmmIn := solveCfmm(tokenInSupply, tokenOutSupply, remReserves, tokenOutAmount.Neg())
inAmt := p.getDescaledPoolAmt(tokenInDenom, cfmmIn.Neg()) // TODO: round mode
return inAmt.SDKDec(), nil
// handle swap fee
inAmt := cfmmIn.Neg().QuoRoundUp(oneMinus(swapFee))
// divide by (1 - swapfee) to force a corresponding increase in input asset
inCoinAmt := p.getDescaledPoolAmt(tokenInDenom, inAmt)
return inCoinAmt, nil
}

func (p *Pool) calcSingleAssetJoinShares(tokenIn sdk.Coin, swapFee sdk.Dec) (sdk.Int, error) {
Expand Down
6 changes: 3 additions & 3 deletions x/gamm/pool-models/stableswap/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func (p Pool) NumAssets() int {
}

// scaledInput returns scaled input tokens for usage in AMM equations
func (p Pool) scaleInputAmount(input sdk.Coin, roundingDirection osmomath.RoundingDirection) (osmomath.BigDec, error) {
func (p Pool) scaleCoin(input sdk.Coin, roundingDirection osmomath.RoundingDirection) (osmomath.BigDec, error) {
liquidityIndexes := p.getLiquidityIndexMap()
scalingFactor := p.GetScalingFactorByLiquidityIndex(liquidityIndexes[input.Denom])
scaledAmount, err := osmomath.DivIntByU64ToBigDec(input.Amount, scalingFactor, roundingDirection)
Expand All @@ -118,13 +118,13 @@ func (p Pool) scaleInputAmount(input sdk.Coin, roundingDirection osmomath.Roundi

// getDescaledPoolAmts gets descaled amount of given denom and amount
// TODO: Review rounding of this in all contexts
func (p Pool) getDescaledPoolAmt(denom string, amount osmomath.BigDec) osmomath.BigDec {
func (p Pool) getDescaledPoolAmt(denom string, amount osmomath.BigDec) sdk.Dec {
liquidityIndexes := p.getLiquidityIndexMap()
liquidityIndex := liquidityIndexes[denom]

scalingFactor := p.GetScalingFactorByLiquidityIndex(liquidityIndex)

return amount.MulInt64(int64(scalingFactor))
return amount.MulInt64(int64(scalingFactor)).SDKDec()
}

// getLiquidityIndexMap creates a map of denoms to its index in pool liquidity
Expand Down
54 changes: 19 additions & 35 deletions x/gamm/pool-models/stableswap/pool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,41 +225,37 @@ func TestGetDescaledPoolAmts(t *testing.T) {
amount osmomath.BigDec
poolAssets sdk.Coins
scalingFactors []uint64
expResult osmomath.BigDec
expResult sdk.Dec
expPanic bool
}{
"pass in no denoms": {
denom: "",
amount: osmomath.ZeroDec(),
poolAssets: twoEvenStablePoolAssets,
scalingFactors: defaultTwoAssetScalingFactors,
expResult: osmomath.ZeroDec(),
expPanic: false,
expResult: sdk.ZeroDec(),
},
// sanity checks, default scaling factors
"get exact supply of one asset, even two-asset pool with default scaling factors": {
denom: "foo",
amount: osmomath.NewBigDec(1000000000),
poolAssets: twoEvenStablePoolAssets,
scalingFactors: defaultTwoAssetScalingFactors,
expResult: osmomath.NewBigDec(1000000000),
expPanic: false,
expResult: sdk.NewDec(1000000000),
},
"get less than supply of one asset, even two-asset pool with default scaling factors": {
denom: "foo",
amount: osmomath.NewBigDec(500000000),
poolAssets: twoEvenStablePoolAssets,
scalingFactors: defaultTwoAssetScalingFactors,
expResult: osmomath.NewBigDec(500000000),
expPanic: false,
expResult: sdk.NewDec(500000000),
},
"get more than supply of one asset, even two-asset pool with default scaling factors": {
denom: "foo",
amount: osmomath.NewBigDec(10000000000000),
poolAssets: twoEvenStablePoolAssets,
scalingFactors: defaultTwoAssetScalingFactors,
expResult: osmomath.NewBigDec(10000000000000),
expPanic: false,
expResult: sdk.NewDec(10000000000000),
},

// uneven pools
Expand All @@ -268,48 +264,42 @@ func TestGetDescaledPoolAmts(t *testing.T) {
amount: osmomath.NewBigDec(2000000000),
poolAssets: twoUnevenStablePoolAssets,
scalingFactors: defaultTwoAssetScalingFactors,
expResult: osmomath.NewBigDec(2000000000),
expPanic: false,
expResult: sdk.NewDec(2000000000),
},
"get less than supply of first asset, uneven two-asset pool with default scaling factors": {
denom: "foo",
amount: osmomath.NewBigDec(500000000),
poolAssets: twoUnevenStablePoolAssets,
scalingFactors: defaultTwoAssetScalingFactors,
expResult: osmomath.NewBigDec(500000000),
expPanic: false,
expResult: sdk.NewDec(500000000),
},
"get more than supply of first asset, uneven two-asset pool with default scaling factors": {
denom: "foo",
amount: osmomath.NewBigDec(10000000000000),
poolAssets: twoUnevenStablePoolAssets,
scalingFactors: defaultTwoAssetScalingFactors,
expResult: osmomath.NewBigDec(10000000000000),
expPanic: false,
expResult: sdk.NewDec(10000000000000),
},
"get exact supply of second asset, uneven two-asset pool with default scaling factors": {
denom: "bar",
amount: osmomath.NewBigDec(1000000000),
poolAssets: twoUnevenStablePoolAssets,
scalingFactors: defaultTwoAssetScalingFactors,
expResult: osmomath.NewBigDec(1000000000),
expPanic: false,
expResult: sdk.NewDec(1000000000),
},
"get less than supply of second asset, uneven two-asset pool with default scaling factors": {
denom: "bar",
amount: osmomath.NewBigDec(500000000),
poolAssets: twoUnevenStablePoolAssets,
scalingFactors: defaultTwoAssetScalingFactors,
expResult: osmomath.NewBigDec(500000000),
expPanic: false,
expResult: sdk.NewDec(500000000),
},
"get more than supply of second asset, uneven two-asset pool with default scaling factors": {
denom: "bar",
amount: osmomath.NewBigDec(10000000000000),
poolAssets: twoUnevenStablePoolAssets,
scalingFactors: defaultTwoAssetScalingFactors,
expResult: osmomath.NewBigDec(10000000000000),
expPanic: false,
expResult: sdk.NewDec(10000000000000),
},

// uneven scaling factors (note: denoms are ordered lexicographically, not by pool asset input)
Expand All @@ -318,48 +308,42 @@ func TestGetDescaledPoolAmts(t *testing.T) {
amount: osmomath.NewBigDec(2000000000),
poolAssets: twoUnevenStablePoolAssets,
scalingFactors: []uint64{10, 5},
expResult: osmomath.NewBigDec(2000000000 * 5),
expPanic: false,
expResult: sdk.NewDec(2000000000 * 5),
},
"get less than supply of first asset, uneven two-asset pool with uneven scaling factors": {
denom: "foo",
amount: osmomath.NewBigDec(500000000),
poolAssets: twoUnevenStablePoolAssets,
scalingFactors: []uint64{10, 5},
expResult: osmomath.NewBigDec(500000000 * 5),
expPanic: false,
expResult: sdk.NewDec(500000000 * 5),
},
"get more than supply of first asset, uneven two-asset pool with uneven scaling factors": {
denom: "foo",
amount: osmomath.NewBigDec(10000000000000),
poolAssets: twoUnevenStablePoolAssets,
scalingFactors: []uint64{10, 5},
expResult: osmomath.NewBigDec(10000000000000 * 5),
expPanic: false,
expResult: sdk.NewDec(10000000000000 * 5),
},
"get exact supply of second asset, uneven two-asset pool with uneven scaling factors": {
denom: "bar",
amount: osmomath.NewBigDec(2000000000),
poolAssets: twoUnevenStablePoolAssets,
scalingFactors: []uint64{10, 5},
expResult: osmomath.NewBigDec(2000000000 * 10),
expPanic: false,
expResult: sdk.NewDec(2000000000 * 10),
},
"get less than supply of second asset, uneven two-asset pool with uneven scaling factors": {
denom: "bar",
amount: osmomath.NewBigDec(500000000),
poolAssets: twoUnevenStablePoolAssets,
scalingFactors: []uint64{10, 5},
expResult: osmomath.NewBigDec(500000000 * 10),
expPanic: false,
expResult: sdk.NewDec(500000000 * 10),
},
"get more than supply of second asset, uneven two-asset pool with uneven scaling factors": {
denom: "bar",
amount: osmomath.NewBigDec(10000000000000),
poolAssets: twoUnevenStablePoolAssets,
scalingFactors: []uint64{10, 5},
expResult: osmomath.NewBigDec(10000000000000 * 10),
expPanic: false,
expResult: sdk.NewDec(10000000000000 * 10),
},

// panic catching
Expand Down Expand Up @@ -404,7 +388,7 @@ func TestGetDescaledPoolAmts(t *testing.T) {
}
}

func TestScaledInput(t *testing.T) {
func TestScaleCoin(t *testing.T) {
tests := map[string]struct {
input sdk.Coin
rounding osmomath.RoundingDirection
Expand Down Expand Up @@ -491,7 +475,7 @@ func TestScaledInput(t *testing.T) {
FuturePoolGovernor: defaultFutureGovernor,
}

scaledInput, err := p.scaleInputAmount(tc.input, tc.rounding)
scaledInput, err := p.scaleCoin(tc.input, tc.rounding)

if !tc.expError {
require.NoError(t, err, "test: %s", name)
Expand Down

0 comments on commit f4c37ab

Please sign in to comment.