From f4c37abec78c52ddf618688721021ef1bd77aa8b Mon Sep 17 00:00:00 2001 From: Dev Ojha Date: Sat, 1 Oct 2022 09:13:45 +0200 Subject: [PATCH] Stableswap: swap, scale rounding & swapfee alignment w/ spec (#2912) * spec update * More spec conformance Co-authored-by: alpo <62043214+AlpinYukseloglu@users.noreply.github.com> --- x/gamm/pool-models/stableswap/README.md | 80 ++++++++++++++++++---- x/gamm/pool-models/stableswap/amm.go | 36 +++++++--- x/gamm/pool-models/stableswap/pool.go | 6 +- x/gamm/pool-models/stableswap/pool_test.go | 54 +++++---------- 4 files changed, 116 insertions(+), 60 deletions(-) diff --git a/x/gamm/pool-models/stableswap/README.md b/x/gamm/pool-models/stableswap/README.md index 4187a2df19a..1f377f34bf5 100644 --- a/x/gamm/pool-models/stableswap/README.md +++ b/x/gamm/pool-models/stableswap/README.md @@ -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. @@ -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`. + ```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 + + +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)`. + + + +```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. diff --git a/x/gamm/pool-models/stableswap/amm.go b/x/gamm/pool-models/stableswap/amm.go index 66ca0dbb473..2c649175047 100644 --- a/x/gamm/pool-models/stableswap/amm.go +++ b/x/gamm/pool-models/stableswap/amm.go @@ -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) { diff --git a/x/gamm/pool-models/stableswap/pool.go b/x/gamm/pool-models/stableswap/pool.go index 7dfba0cc606..46660b4d750 100644 --- a/x/gamm/pool-models/stableswap/pool.go +++ b/x/gamm/pool-models/stableswap/pool.go @@ -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) @@ -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 diff --git a/x/gamm/pool-models/stableswap/pool_test.go b/x/gamm/pool-models/stableswap/pool_test.go index fdf2c170113..5cbc7f82b11 100644 --- a/x/gamm/pool-models/stableswap/pool_test.go +++ b/x/gamm/pool-models/stableswap/pool_test.go @@ -225,7 +225,7 @@ 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": { @@ -233,8 +233,7 @@ func TestGetDescaledPoolAmts(t *testing.T) { 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": { @@ -242,24 +241,21 @@ func TestGetDescaledPoolAmts(t *testing.T) { 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 @@ -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) @@ -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 @@ -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 @@ -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)