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 2 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
15 changes: 2 additions & 13 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,14 @@
"type": "go",
"request": "launch",
"mode": "test",
"program": "${workspaceFolder}/tests/e2e",
"program": "${workspaceFolder}/x/concentrated-liquidity",
"args": [
"-test.timeout",
"30m",
"-test.run",
"IntegrationTestSuite",
"TestKeeperTestSuite/TestClaimAndResetFullRangeBalancerPool",
"-test.v"
],
"buildFlags": "-tags e2e",
"env": {
"OSMOSIS_E2E": "True",
"OSMOSIS_E2E_SKIP_IBC": "true",
"OSMOSIS_E2E_SKIP_UPGRADE": "true",
"OSMOSIS_E2E_SKIP_CLEANUP": "true",
"OSMOSIS_E2E_SKIP_STATE_SYNC": "true",
"OSMOSIS_E2E_UPGRADE_VERSION": "v16",
"OSMOSIS_E2E_DEBUG_LOG": "false",
},
"preLaunchTask": "e2e-setup"
}
]
}
10 changes: 10 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,13 @@ 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
];
}
21 changes: 21 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 MsgSplitRouteSwapExactAmountInInResponse {
p0mvn marked this conversation as resolved.
Show resolved Hide resolved
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 Down
58 changes: 58 additions & 0 deletions x/poolmanager/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,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.ValidateSplitRoutes(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.
p0mvn marked this conversation as resolved.
Show resolved Hide resolved
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
138 changes: 138 additions & 0 deletions x/poolmanager/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1530,3 +1530,141 @@ func (suite *KeeperTestSuite) setupPools(poolType types.PoolType, poolDefaultSwa
return
}
}

func (suite *KeeperTestSuite) TestSplitRouteExactAmountIn() {

type poolSetup struct {
poolType types.PoolType
initialLiquidity sdk.Coins
}

var (
defaultAmount = sdk.NewInt(10_000_000_000)

fooCoin = sdk.NewCoin(foo, defaultAmount)
barCoin = sdk.NewCoin(bar, defaultAmount)
bazCoin = sdk.NewCoin(baz, defaultAmount)
uosmoCoin = sdk.NewCoin(uosmo, defaultAmount)

fooBarCoins = sdk.NewCoins(fooCoin, barCoin)
fooBazCoins = sdk.NewCoins(fooCoin, bazCoin)
fooUosmoCoins = sdk.NewCoins(fooCoin, uosmoCoin)
barBazCoins = sdk.NewCoins(barCoin, bazCoin)
barUosmoCoins = sdk.NewCoins(barCoin, uosmoCoin)
bazUosmoCoins = sdk.NewCoins(bazCoin, uosmoCoin)

defaultValidPools = []poolSetup{
{
poolType: types.Balancer,
initialLiquidity: fooBarCoins,
},
{
poolType: types.Concentrated,
initialLiquidity: fooBazCoins,
},
{
poolType: types.Balancer,
initialLiquidity: fooUosmoCoins,
},
{
poolType: types.Concentrated,
initialLiquidity: barBazCoins,
},
{
poolType: types.Balancer,
initialLiquidity: barUosmoCoins,
},
{
poolType: types.Concentrated,
initialLiquidity: bazUosmoCoins,
},
}
)

tests := map[string]struct {
poolSetup []poolSetup
isInvalidSender bool
routes []types.SwapAmountInSplitRoute
tokenInDenom string
tokenOutMinAmount sdk.Int
expectedTokenOut sdk.Int

expectError error
}{
"valid split route multi hop": {
poolSetup: defaultValidPools,
routes: []types.SwapAmountInSplitRoute{
{
Pools: []types.SwapAmountInRoute{
{
PoolId: 1,
TokenOutDenom: bar,
},
{
PoolId: 4,
TokenOutDenom: baz,
},
},
TokenInAmount: sdk.NewInt(25_000_000),
},
{
Pools: []types.SwapAmountInRoute{
{
PoolId: 1,
TokenOutDenom: bar,
},
{
PoolId: 5,
TokenOutDenom: uosmo,
},
{
PoolId: 6,
TokenOutDenom: baz,
},
},
TokenInAmount: sdk.NewInt(75_000_000),
},
},
tokenInDenom: foo,
tokenOutMinAmount: sdk.OneInt(),

// TODO: confirm amount is correct
expectedTokenOut: sdk.NewInt(97866545),
},

// TODO: error and edge cases.
}

suite.PrepareBalancerPool()
suite.PrepareConcentratedPool()

for name, tc := range tests {
tc := tc
suite.Run(name, func() {
suite.SetupTest()
k := suite.App.PoolManagerKeeper

sender := suite.TestAccs[1]

for _, pool := range tc.poolSetup {
suite.CreatePoolFromTypeWithCoins(pool.poolType, pool.initialLiquidity)

// Fund sender with initial liqudity
// If not valid, we don't fund to trigger an error case.
if !tc.isInvalidSender {
suite.FundAcc(sender, pool.initialLiquidity)
}
}

tokenOut, err := k.SplitRouteExactAmountIn(suite.Ctx, sender, tc.routes, tc.tokenInDenom, tc.tokenOutMinAmount)

if tc.expectError != nil {
suite.Require().Error(err)
return
}
suite.Require().NoError(err)

suite.Require().Equal(tc.expectedTokenOut.String(), tokenOut.String())
})
}
}
46 changes: 46 additions & 0 deletions x/poolmanager/types/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ package types
import (
"errors"
"fmt"

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

const (
amountOutPlaceholder = "out"
amountInPlaceholder = "in"
)

var (
Expand Down Expand Up @@ -36,3 +43,42 @@ type UndefinedRouteError struct {
func (e UndefinedRouteError) Error() string {
return fmt.Sprintf("route is not defined for the given pool type (%s) and pool id (%d)", e.PoolType, e.PoolId)
}

type FinalAmountIsNotPositiveError struct {
IsAmountOut bool
Amount sdk.Int
}

func (e FinalAmountIsNotPositiveError) Error() string {
amountPlaceholder := amountOutPlaceholder
if !e.IsAmountOut {
amountPlaceholder = amountInPlaceholder
}
return fmt.Sprintf("final total amount (%s) must be positive, was (%d)", amountPlaceholder, e.Amount)
}

type PriceImpactProtectionExactInError struct {
Actual sdk.Int
MinAmount sdk.Int
}

func (e PriceImpactProtectionExactInError) Error() string {
return fmt.Sprintf("price impact protection: expected %s be at least %s", e.Actual, e.MinAmount)
}

type InvalidFinalTokenOutError struct {
TokenOutGivenA string
TokenOutGivenB string
}

func (e InvalidFinalTokenOutError) Error() string {
return fmt.Sprintf("invalid final token out, each path must end on the same token out, had (%s) and (%s) mismatch", e.TokenOutGivenA, e.TokenOutGivenB)
}

type InvalidSenderError struct {
Sender string
}

func (e InvalidSenderError) Error() string {
return fmt.Sprintf("Invalid sender address (%s)", e.Sender)
}
Loading