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(x/poolmanager): split routes swap message #4886

Merged
merged 26 commits into from
Apr 14, 2023
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* [#4659](https://github.com/osmosis-labs/osmosis/pull/4659) implement AllPools query in x/poolmanager.
* [#4783](https://github.com/osmosis-labs/osmosis/pull/4783) Update wasmd to 0.31.0
* [#4886](https://github.com/osmosis-labs/osmosis/pull/4886) Implement MsgSplitRouteSwapExactAmountIn and MsgSplitRouteSwapExactAmountOut that supports route splitting.

### Misc Improvements

Expand All @@ -56,8 +57,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* [#4891](https://github.com/osmosis-labs/osmosis/pull/4891) Enable CORS by default on localosmosis
* [#4893](https://github.com/osmosis-labs/osmosis/pull/4893) Update alpine docker base image to `alpine:3.17`

### API Breaks

### API breaks

* [#4336](https://github.com/osmosis-labs/osmosis/pull/4336) Move epochs module into its own go.mod
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ require (
github.com/ory/dockertest/v3 v3.9.1
github.com/osmosis-labs/go-mutesting v0.0.0-20221208041716-b43bcd97b3b3
github.com/osmosis-labs/osmosis/osmomath v0.0.3-dev.0.20230328024000-175ec88e4304
github.com/osmosis-labs/osmosis/osmoutils v0.0.0-20230405221332-6db5383670e1
github.com/osmosis-labs/osmosis/osmoutils v0.0.0-20230411200859-ae3065d0ca05
github.com/osmosis-labs/osmosis/x/epochs v0.0.0-20230328024000-175ec88e4304
github.com/osmosis-labs/osmosis/x/ibc-hooks v0.0.0-20230331072320-5d6f6cfa2627
github.com/pkg/errors v0.9.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -934,8 +934,8 @@ github.com/osmosis-labs/go-mutesting v0.0.0-20221208041716-b43bcd97b3b3 h1:Ylmch
github.com/osmosis-labs/go-mutesting v0.0.0-20221208041716-b43bcd97b3b3/go.mod h1:lV6KnqXYD/ayTe7310MHtM3I2q8Z6bBfMAi+bhwPYtI=
github.com/osmosis-labs/osmosis/osmomath v0.0.3-dev.0.20230328024000-175ec88e4304 h1:iSSlHl+SoewNpP/2N8JaUEHhOQRmJAnS8zaJ11yWslY=
github.com/osmosis-labs/osmosis/osmomath v0.0.3-dev.0.20230328024000-175ec88e4304/go.mod h1:/h3CZIo25kMrM4Ojm7qBgMxKofTVwOycVWSa4rhEsaM=
github.com/osmosis-labs/osmosis/osmoutils v0.0.0-20230405221332-6db5383670e1 h1:oq28hQA8wHe1uNMHWsoSMJvxMkKQrIeg8PcQD1KkHYg=
github.com/osmosis-labs/osmosis/osmoutils v0.0.0-20230405221332-6db5383670e1/go.mod h1:zyBrzl2rsZWGbOU+/1hzA+xoQlCshzZuHe/5mzdb/zo=
github.com/osmosis-labs/osmosis/osmoutils v0.0.0-20230411200859-ae3065d0ca05 h1:fqVGxZPgUWuYWxVcMxHz5vrDV/aoxGJ7Kt0J4Vu/bsY=
github.com/osmosis-labs/osmosis/osmoutils v0.0.0-20230411200859-ae3065d0ca05/go.mod h1:zyBrzl2rsZWGbOU+/1hzA+xoQlCshzZuHe/5mzdb/zo=
github.com/osmosis-labs/osmosis/x/epochs v0.0.0-20230328024000-175ec88e4304 h1:RIrWLzIiZN5Xd2JOfSOtGZaf6V3qEQYg6EaDTAkMnCo=
github.com/osmosis-labs/osmosis/x/epochs v0.0.0-20230328024000-175ec88e4304/go.mod h1:yPWoJTj5RKrXKUChAicp+G/4Ni/uVEpp27mi/FF/L9c=
github.com/osmosis-labs/osmosis/x/ibc-hooks v0.0.0-20230331072320-5d6f6cfa2627 h1:A0SwZgp4bmJFbivYJc8mmVhMjrr3EdUZluBYFke11+w=
Expand Down
14 changes: 14 additions & 0 deletions osmoutils/slice_helper.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package osmoutils

import (
"reflect"
"sort"

"golang.org/x/exp/constraints"
Expand Down Expand Up @@ -50,6 +51,19 @@ func ContainsDuplicate[T any](arr []T) bool {
return false
}

// ContainsDuplicateDeepEqual returns true if there are duplicates
// in the slice by performing deep comparison. This is useful
// for comparing matrices or slices of pointers.
// Returns false if there are no deep equal duplicates.
func ContainsDuplicateDeepEqual[T any](multihops []T) bool {
for i := 0; i < len(multihops)-1; i++ {
if reflect.DeepEqual(multihops[i], multihops[i+1]) {
return true
}
}
return false
}

type LessFunc[T any] func(a, b T) bool

// MergeSlices efficiently merges two sorted slices into a single sorted slice.
Expand Down
18 changes: 18 additions & 0 deletions osmoutils/slice_helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,21 @@ func TestMergeSlices(t *testing.T) {
})
}
}

func TestContainsDuplicateDeepEqual(t *testing.T) {
tests := []struct {
input []interface{}
want bool
}{
{[]interface{}{[]int{1, 2, 3}, []int{4, 5, 6}}, false},
{[]interface{}{[]int{1, 2, 3}, []int{1, 2, 3}}, true},
{[]interface{}{[]string{"hello", "world"}, []string{"goodbye", "world"}}, false},
{[]interface{}{[]string{"hello", "world"}, []string{"hello", "world"}}, true},
{[]interface{}{[][]int{{1, 2}, {3, 4}}, [][]int{{1, 2}, {3, 4}}}, true},
}

for _, tt := range tests {
got := osmoutils.ContainsDuplicateDeepEqual(tt.input)
require.Equal(t, tt.want, got)
}
}
20 changes: 20 additions & 0 deletions proto/osmosis/poolmanager/v1beta1/swap_route.proto
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,23 @@ message SwapAmountOutRoute {
string token_in_denom = 2
[ (gogoproto.moretags) = "yaml:\"token_in_denom\"" ];
}

message SwapAmountInSplitRoute {
repeated SwapAmountInRoute pools = 1
[ (gogoproto.moretags) = "yaml:\"pools\"", (gogoproto.nullable) = false ];
p0mvn marked this conversation as resolved.
Show resolved Hide resolved
string token_in_amount = 2 [
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Int",
(gogoproto.moretags) = "yaml:\"token_in_amount\"",
(gogoproto.nullable) = false
];
}

message SwapAmountOutSplitRoute {
repeated SwapAmountOutRoute pools = 1
[ (gogoproto.moretags) = "yaml:\"pools\"", (gogoproto.nullable) = false ];
p0mvn marked this conversation as resolved.
Show resolved Hide resolved
string token_out_amount = 2 [
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Int",
(gogoproto.moretags) = "yaml:\"token_out_amount\"",
(gogoproto.nullable) = false
];
}
42 changes: 42 additions & 0 deletions proto/osmosis/poolmanager/v1beta1/tx.proto
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@ message MsgSwapExactAmountInResponse {
];
}

// ===================== MsgSplitRouteSwapExactAmountIn
message MsgSplitRouteSwapExactAmountIn {
string sender = 1 [ (gogoproto.moretags) = "yaml:\"sender\"" ];
repeated SwapAmountInSplitRoute routes = 2 [ (gogoproto.nullable) = false ];
string token_in_denom = 3
[ (gogoproto.moretags) = "yaml:\"token_in_denom\"" ];
string token_out_min_amount = 4 [
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Int",
(gogoproto.moretags) = "yaml:\"token_out_min_amount\"",
(gogoproto.nullable) = false
];
}

message MsgSplitRouteSwapExactAmountInResponse {
string token_out_amount = 1 [
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Int",
(gogoproto.moretags) = "yaml:\"token_out_amount\"",
(gogoproto.nullable) = false
];
p0mvn marked this conversation as resolved.
Show resolved Hide resolved
}

// ===================== MsgSwapExactAmountOut
message MsgSwapExactAmountOut {
string sender = 1 [ (gogoproto.moretags) = "yaml:\"sender\"" ];
Expand All @@ -59,3 +80,24 @@ message MsgSwapExactAmountOutResponse {
(gogoproto.nullable) = false
];
}

// ===================== MsgSplitRouteSwapExactAmountOut
message MsgSplitRouteSwapExactAmountOut {
string sender = 1 [ (gogoproto.moretags) = "yaml:\"sender\"" ];
repeated SwapAmountOutSplitRoute routes = 2 [ (gogoproto.nullable) = false ];
string token_out_denom = 3
[ (gogoproto.moretags) = "yaml:\"token_out_denom\"" ];
string token_in_max_amount = 4 [
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Int",
(gogoproto.moretags) = "yaml:\"token_in_max_amount\"",
(gogoproto.nullable) = false
];
}

message MsgSplitRouteSwapExactAmountOutResponse {
string token_in_amount = 1 [
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Int",
(gogoproto.moretags) = "yaml:\"token_in_amount\"",
(gogoproto.nullable) = false
];
}
p0mvn marked this conversation as resolved.
Show resolved Hide resolved
43 changes: 41 additions & 2 deletions x/poolmanager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,19 @@ func ParseModuleRouteFromBz(bz []byte) (ModuleRoute, error) {

## Swaps

There are 2 swap messages:
There are 4 swap messages:

- `MsgSwapExactAmountIn`
- `MsgSwapExactAmountOut`
- `MsgSplitRouteSwapExactAmountIn`
- `MsgSplitRouteSwapExactAmountOut`

Their implementation of routing is similar. As a result, we only focus on `MsgSwapExactAmountIn`.
Between, `MsgSwapExactAmountIn` and `MsgSwapExactAmountOut`, the implementation of routing is similar. We only focus on `MsgSwapExactAmountIn` below.

`MsgSplitRouteSwapExactAmountIn` and `MsgSplitRouteSwapExactAmountOut` support split routes where for each split route they call the respective
`MsgSwapExactAmountIn` or `MsgSwapExactAmountOut` message. When using the split routes, the slippage protection is disabled on the per-route basis.
For swap exact amount in, we provide zero for the min amount out. For swap exact amount out, we provide the max amount in which is 1 << 256 - 1.
Read more about route splitting in the "Route Splitting" section.

Once the message is received, it calls `RouteExactAmountIn`

Expand Down Expand Up @@ -277,6 +284,13 @@ Existing Swap types:

[MsgSwapExactAmountOut](https://github.com/osmosis-labs/osmosis/blob/f26ceb958adaaf31510e17ed88f5eab47e2bac03/proto/osmosis/gamm/v1beta1/tx.proto#L102)

### MsgSplitRouteSwapExactAmountIn

[MsgSplitRouteSwapExactAmountIn](https://github.com/osmosis-labs/osmosis/blob/46e6a0c2051a3a5ef8cdd4ecebfff7305b13ab98/proto/osmosis/poolmanager/v1beta1/tx.proto#L41)

## MsgSplitRouteSwapExactAmountOut

[MsgSplitRouteSwapExactAmountOut](https://github.com/osmosis-labs/osmosis/blob/46e6a0c2051a3a5ef8cdd4ecebfff7305b13ab98/proto/osmosis/poolmanager/v1beta1/tx.proto#L85)

## Multi-Hop

Expand All @@ -292,3 +306,28 @@ Example: for converting `ATOM -> OSMO -> LUNA` using two pools with swap fees `0
instead `0.15% + 0.1%` fees will be aplied.

[Multi-Hop](https://github.com/osmosis-labs/osmosis/blob/f26ceb958adaaf31510e17ed88f5eab47e2bac03/x/poolmanager/router.go#L16)

## Route Splitting

Each route can be thought of as a separate multi-hop swap.
p0mvn marked this conversation as resolved.
Show resolved Hide resolved

Splitting swaps across multiple pools for the same token pair can be beneficial for several reasons,
primarily relating to reduced slippage, price impact, and potentially lower fees.

Here's a detailed explanation of these advantages:

- **Reduced slippage**: When a large trade is executed in a single pool, it can be significantly affected if someone else executes a large swap against that pool.

- **Lower price impact**: when executing a large trade in a single pool, the price impact can be substantial, leading to a less favorable exchange rate for the trader.
By splitting the swap across multiple pools, the price impact in each pool is minimized, resulting in a better overall exchange rate.

- **Improved liquidity utilization**: Different pools may have varying levels of liquidity, fees, and price curves. By splitting swaps across multiple pools,
the router can utilize liquidity from various sources, allowing for more efficient execution of trades. This is particularly useful when the liquidity in
a single pool is not sufficient to handle a large trade or when the price curve of one pool becomes less favorable as the trade size increases.

- **Potentially lower fees**: In some cases, splitting swaps across multiple pools may result in lower overall fees. This can happen when different pools
have different fee structures, or when the total fee paid across multiple pools is lower than the fee for executing the entire trade in a single pool with
higher slippage.

Note, that the actual split happens off-chain. The router is only responsible for executing the swaps in the order and quantities of token in provided
by the routes.
4 changes: 4 additions & 0 deletions x/poolmanager/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import (
"github.com/osmosis-labs/osmosis/v15/x/poolmanager/types"
)

var (
IntMaxValue = intMaxValue
)

func (k Keeper) GetNextPoolIdAndIncrement(ctx sdk.Context) uint64 {
return k.getNextPoolIdAndIncrement(ctx)
}
Expand Down
123 changes: 123 additions & 0 deletions x/poolmanager/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package poolmanager
import (
"errors"
"fmt"
"math/big"

sdk "github.com/cosmos/cosmos-sdk/types"

Expand All @@ -11,6 +12,11 @@ import (
"github.com/osmosis-labs/osmosis/v15/x/poolmanager/types"
)

var (
// 1 << 256 - 1 where 256 is the max bit length defined for sdk.Int
intMaxValue = sdk.NewIntFromBigInt(new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)))
)

// RouteExactAmountIn defines the input denom and input amount for the first pool,
// the output of the first pool is chained as the input for the next routed pool
// transaction succeeds when final amount out is greater than tokenOutMinAmount defined.
Expand Down Expand Up @@ -93,6 +99,64 @@ func (k Keeper) RouteExactAmountIn(
return tokenOutAmount, nil
}

// SplitRouteExactAmountIn routes the swap across multiple multihop paths
// to get the desired token out. This is useful for achieving the most optimal execution. However, note that the responsibility
// of determining the optimal split is left to the client. This method simply routes the swap across the given routes.
//
// It performs the price impact protection check on the combination of tokens out from all multihop paths. The given tokenOutMinAmount
// is used for comparison.
//
// Returns error if:
// - routes are empty
// - routes contain duplicate multihop paths
// - last token out denom is not the same for all multihop paths in route
// - one of the multihop swaps fails for internal reasons
// - final token out computed is not positive
// - final token out computed is smaller than tokenOutMinAmount
p0mvn marked this conversation as resolved.
Show resolved Hide resolved
func (k Keeper) SplitRouteExactAmountIn(
ctx sdk.Context,
sender sdk.AccAddress,
routes []types.SwapAmountInSplitRoute,
tokenInDenom string,
tokenOutMinAmount sdk.Int,
) (sdk.Int, error) {
if err := types.ValidateSwapAmountInSplitRoute(routes); err != nil {
return sdk.Int{}, err
}

var (
// We start the multihop min amount as zero because we want
// to perform a price impact protection check on the combination of tokens out
// from all multihop paths.
multihopStartTokenOutMinAmount = sdk.ZeroInt()
totalOutAmount = sdk.ZeroInt()
)

for _, multihopRoute := range routes {
tokenOutAmount, err := k.RouteExactAmountIn(
ctx,
sender,
types.SwapAmountInRoutes(multihopRoute.Pools),
sdk.NewCoin(tokenInDenom, multihopRoute.TokenInAmount),
multihopStartTokenOutMinAmount)
if err != nil {
return sdk.Int{}, err
}

totalOutAmount = totalOutAmount.Add(tokenOutAmount)
}

if !totalOutAmount.IsPositive() {
return sdk.Int{}, types.FinalAmountIsNotPositiveError{IsAmountOut: true, Amount: totalOutAmount}
}

if totalOutAmount.LT(tokenOutMinAmount) {
return sdk.Int{}, types.PriceImpactProtectionExactInError{Actual: totalOutAmount, MinAmount: tokenOutMinAmount}
}

return totalOutAmount, nil
}

// SwapExactAmountIn is an API for swapping an exact amount of tokens
// as input to a pool to get a minimum amount of the desired token out.
// The method succeeds when tokenOutAmount is greater than tokenOutMinAmount defined.
Expand Down Expand Up @@ -305,6 +369,65 @@ func (k Keeper) RouteExactAmountOut(ctx sdk.Context,
return tokenInAmount, nil
}

// SplitRouteExactAmountOut routes the swap across multiple multihop paths
// to get the desired token in. This is useful for achieving the most optimal execution. However, note that the responsibility
// of determining the optimal split is left to the client. This method simply routes the swap across the given routes.
//
// It performs the price impact protection check on the combination of tokens in from all multihop paths. The given tokenInMaxAmount
// is used for comparison.
p0mvn marked this conversation as resolved.
Show resolved Hide resolved
//
// Returns error if:
// - routes are empty
// - routes contain duplicate multihop paths
// - last token out denom is not the same for all multihop paths in route
// - one of the multihop swaps fails for internal reasons
// - final token out computed is not positive
// - final token out computed is smaller than tokenInMaxAmount
func (k Keeper) SplitRouteExactAmountOut(
ctx sdk.Context,
sender sdk.AccAddress,
routes []types.SwapAmountOutSplitRoute,
tokenOutDenom string,
tokenInMaxAmount sdk.Int,
) (sdk.Int, error) {
if err := types.ValidateSwapAmountOutSplitRoute(routes); err != nil {
return sdk.Int{}, err
}

var (
// We start the multihop min amount as int max value
// that is defined as one under the max bit length of sdk.Int
// which is 256. This is to ensure that we utilize price impact protection
// on the total of in amount from all multihop paths.
multihopStartTokenInMaxAmount = intMaxValue
totalInAmount = sdk.ZeroInt()
)

for _, multihopRoute := range routes {
tokenOutAmount, err := k.RouteExactAmountOut(
ctx,
sender,
types.SwapAmountOutRoutes(multihopRoute.Pools),
multihopStartTokenInMaxAmount,
sdk.NewCoin(tokenOutDenom, multihopRoute.TokenOutAmount))
if err != nil {
return sdk.Int{}, err
}

totalInAmount = totalInAmount.Add(tokenOutAmount)
}

if !totalInAmount.IsPositive() {
return sdk.Int{}, types.FinalAmountIsNotPositiveError{IsAmountOut: false, Amount: totalInAmount}
}

if totalInAmount.GT(tokenInMaxAmount) {
return sdk.Int{}, types.PriceImpactProtectionExactOutError{Actual: totalInAmount, MaxAmount: tokenInMaxAmount}
}

return totalInAmount, nil
}

func (k Keeper) RouteGetPoolDenoms(
ctx sdk.Context,
poolId uint64,
Expand Down
Loading