From 10eca0f94ea743e292c25a3c0038fee73a1903a5 Mon Sep 17 00:00:00 2001 From: Roman Date: Sun, 3 Nov 2024 13:42:48 -0700 Subject: [PATCH] feat: simulate swap as part of quotes --- domain/cosmos/tx/msg_simulator.go | 47 +++++++++++----- domain/mocks/msg_simulator_mock.go | 21 +++++++ domain/quote_simulator.go | 23 ++++++++ domain/router.go | 3 + quotesimulator/quote_simulator.go | 77 ++++++++++++++++++++++++++ router/delivery/http/router_handler.go | 35 +++++++++--- router/types/get_quote_request.go | 64 ++++++++++++++++++--- router/usecase/quote_out_given_in.go | 7 +++ 8 files changed, 250 insertions(+), 27 deletions(-) create mode 100644 domain/quote_simulator.go create mode 100644 quotesimulator/quote_simulator.go diff --git a/domain/cosmos/tx/msg_simulator.go b/domain/cosmos/tx/msg_simulator.go index 0778fffd0..26db251d5 100644 --- a/domain/cosmos/tx/msg_simulator.go +++ b/domain/cosmos/tx/msg_simulator.go @@ -38,6 +38,17 @@ type MsgSimulator interface { chainID string, msgs []sdk.Msg, ) (*txtypes.SimulateResponse, uint64, error) + + // PriceMsgs simulates the execution of the given messages and returns the gas used and the fee coin, + // which is the fee amount in the base denomination. + PriceMsgs( + ctx context.Context, + txfeesClient txfeestypes.QueryClient, + encodingConfig cosmosclient.TxConfig, + account *authtypes.BaseAccount, + chainID string, + msg ...sdk.Msg, + ) (uint64, sdk.Coin, error) } // NewGasCalculator creates a new GasCalculator instance. @@ -79,23 +90,13 @@ func (c *txGasCalulator) BuildTx( return nil, err } - _, gas, err := c.SimulateMsgs( - encodingConfig.TxConfig, - account, - chainID, - msg, - ) + gasAdjusted, feecoin, err := c.PriceMsgs(ctx, txfeesClient, encodingConfig.TxConfig, account, chainID, msg...) if err != nil { return nil, err } - txBuilder.SetGasLimit(gas) - feecoin, err := CalculateFeeCoin(ctx, txfeesClient, gas) - if err != nil { - return nil, err - } - - txBuilder.SetFeeAmount(sdk.NewCoins(feecoin)) + txBuilder.SetGasLimit(gasAdjusted) + txBuilder.SetFeeAmount(sdk.Coins{feecoin}) sigV2 := BuildSignatures(privKey.PubKey(), nil, account.Sequence) err = txBuilder.SetSignatures(sigV2) @@ -143,6 +144,26 @@ func (c *txGasCalulator) SimulateMsgs(encodingConfig cosmosclient.TxConfig, acco return gasResult, adjustedGasUsed, nil } +// PriceMsgs implements MsgSimulator. +func (c *txGasCalulator) PriceMsgs(ctx context.Context, txfeesClient txfeestypes.QueryClient, encodingConfig cosmosclient.TxConfig, account *authtypes.BaseAccount, chainID string, msg ...sdk.Msg) (uint64, sdk.Coin, error) { + _, gasAdjusted, err := c.SimulateMsgs( + encodingConfig, + account, + chainID, + msg, + ) + if err != nil { + return 0, sdk.Coin{}, err + } + + feecoin, err := CalculateFeeCoin(ctx, txfeesClient, gasAdjusted) + if err != nil { + return 0, sdk.Coin{}, err + } + + return gasAdjusted, feecoin, nil +} + // CalculateGas calculates the gas required for a transaction using the provided transaction factory and messages. func CalculateGas( clientCtx gogogrpc.ClientConn, diff --git a/domain/mocks/msg_simulator_mock.go b/domain/mocks/msg_simulator_mock.go index f6b59c307..86c014d5f 100644 --- a/domain/mocks/msg_simulator_mock.go +++ b/domain/mocks/msg_simulator_mock.go @@ -30,6 +30,15 @@ type MsgSimulatorMock struct { chainID string, msgs []sdk.Msg, ) (*txtypes.SimulateResponse, uint64, error) + + PriceMsgsFn func( + ctx context.Context, + txfeesClient txfeestypes.QueryClient, + encodingConfig client.TxConfig, + account *authtypes.BaseAccount, + chainID string, + msg ...sdk.Msg, + ) (uint64, sdk.Coin, error) } var _ sqstx.MsgSimulator = &MsgSimulatorMock{} @@ -59,3 +68,15 @@ func (m *MsgSimulatorMock) SimulateMsgs( } panic("SimulateMsgsFn not implemented") } + +// PriceMsgs implements tx.MsgSimulator. +func (m *MsgSimulatorMock) PriceMsgs(ctx context.Context, txfeesClient txfeestypes.QueryClient, encodingConfig client.TxConfig, account *authtypes.BaseAccount, chainID string, msg ...interface { + ProtoMessage() + Reset() + String() string +}) (uint64, sdk.Coin, error) { + if m.PriceMsgsFn != nil { + return m.PriceMsgsFn(ctx, txfeesClient, encodingConfig, account, chainID, msg...) + } + panic("PriceMsgsFn not implemented") +} diff --git a/domain/quote_simulator.go b/domain/quote_simulator.go new file mode 100644 index 000000000..ce116cb34 --- /dev/null +++ b/domain/quote_simulator.go @@ -0,0 +1,23 @@ +package domain + +import ( + "context" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/osmosis-labs/osmosis/osmomath" +) + +// QuoteSimulator simulates a quote and returns the gas adjusted amount and the fee coin. +type QuoteSimulator interface { + // SimulateQuote simulates a quote and returns the gas adjusted amount and the fee coin. + // CONTRACT: + // - Only direct (non-split) quotes are supported. + // Retursn error if: + // - Simulator address does not have enough funds to pay for the quote. + SimulateQuote(ctx context.Context, quote Quote, slippageToleranceMultiplier osmomath.Dec, simulatorAddress string) (uint64, sdk.Coin, error) +} + +type QuotePriceInfo struct { + AdjustedGasUsed uint64 `json:"adjusted_gas_used"` + FeeCoin sdk.Coin `json:"fee_coin"` +} diff --git a/domain/router.go b/domain/router.go index ce1816512..748718cf9 100644 --- a/domain/router.go +++ b/domain/router.go @@ -72,6 +72,9 @@ type Quote interface { // for the tokens. In that case, we invalidate spot price by setting it to zero. PrepareResult(ctx context.Context, scalingFactor osmomath.Dec, logger log.Logger) ([]SplitRoute, osmomath.Dec, error) + // SetQuotePriceInfo sets the quote price info. + SetQuotePriceInfo(info *QuotePriceInfo) + String() string } diff --git a/quotesimulator/quote_simulator.go b/quotesimulator/quote_simulator.go new file mode 100644 index 000000000..91e8b13ae --- /dev/null +++ b/quotesimulator/quote_simulator.go @@ -0,0 +1,77 @@ +package quotesimulator + +import ( + "context" + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/osmosis-labs/osmosis/osmomath" + "github.com/osmosis-labs/osmosis/v26/app/params" + txfeestypes "github.com/osmosis-labs/osmosis/v26/x/txfees/types" + "github.com/osmosis-labs/sqs/domain" + "github.com/osmosis-labs/sqs/domain/cosmos/auth/types" + "github.com/osmosis-labs/sqs/domain/cosmos/tx" + + poolmanagertypes "github.com/osmosis-labs/osmosis/v26/x/poolmanager/types" +) + +type quoteSimulator struct { + gasCalculator tx.MsgSimulator + encodingConfig params.EncodingConfig + txFeesClient txfeestypes.QueryClient + accountQueryClient types.QueryClient + chainID string +} + +func NewQuoteSimulator(gasCalculator tx.MsgSimulator, bech32Address string, encodingConfig params.EncodingConfig, txFeesClient txfeestypes.QueryClient, accountQueryClient types.QueryClient, chainID string) *quoteSimulator { + return "eSimulator{ + gasCalculator: gasCalculator, + encodingConfig: encodingConfig, + txFeesClient: txFeesClient, + accountQueryClient: accountQueryClient, + chainID: chainID, + } +} + +func (q *quoteSimulator) SimulateQuote(ctx context.Context, quote domain.Quote, slippageToleranceMultiplier osmomath.Dec, simulatorAddress string) (uint64, sdk.Coin, error) { + route := quote.GetRoute() + + if len(route) != 1 { + return 0, sdk.Coin{}, fmt.Errorf("route length must be 1, got %d", len(route)) + } + + poolsInRoute := route[0].GetPools() + + poolManagerRoute := make([]poolmanagertypes.SwapAmountInRoute, len(poolsInRoute)) + for i, r := range poolsInRoute { + poolManagerRoute[i] = poolmanagertypes.SwapAmountInRoute{ + PoolId: r.GetId(), + TokenOutDenom: r.GetTokenOutDenom(), + } + } + + tokenIn := quote.GetAmountIn() + + slippageBound := tokenIn.Amount.ToLegacyDec().Mul(slippageToleranceMultiplier).TruncateInt() + + swapMsg := &poolmanagertypes.MsgSwapExactAmountIn{ + Sender: simulatorAddress, + Routes: poolManagerRoute, + TokenIn: tokenIn, + TokenOutMinAmount: slippageBound, + } + + baseAccount, err := q.accountQueryClient.GetAccount(ctx, simulatorAddress) + if err != nil { + return 0, sdk.Coin{}, err + } + + gasAdjusted, feeCoin, err := q.gasCalculator.PriceMsgs(ctx, q.txFeesClient, q.encodingConfig.TxConfig, baseAccount, q.chainID, swapMsg) + if err != nil { + return 0, sdk.Coin{}, err + } + + return gasAdjusted, feeCoin, nil +} + +var _ domain.QuoteSimulator = "eSimulator{} diff --git a/router/delivery/http/router_handler.go b/router/delivery/http/router_handler.go index df9928951..e51b9172a 100644 --- a/router/delivery/http/router_handler.go +++ b/router/delivery/http/router_handler.go @@ -19,9 +19,10 @@ import ( // RouterHandler represent the httphandler for the router type RouterHandler struct { - RUsecase mvc.RouterUsecase - TUsecase mvc.TokensUsecase - logger log.Logger + RUsecase mvc.RouterUsecase + TUsecase mvc.TokensUsecase + QuoteSimulator domain.QuoteSimulator + logger log.Logger } const routerResource = "/router" @@ -35,11 +36,12 @@ func formatRouterResource(resource string) string { } // NewRouterHandler will initialize the pools/ resources endpoint -func NewRouterHandler(e *echo.Echo, us mvc.RouterUsecase, tu mvc.TokensUsecase, logger log.Logger) { +func NewRouterHandler(e *echo.Echo, us mvc.RouterUsecase, tu mvc.TokensUsecase, qs domain.QuoteSimulator, logger log.Logger) { handler := &RouterHandler{ - RUsecase: us, - TUsecase: tu, - logger: logger, + RUsecase: us, + TUsecase: tu, + QuoteSimulator: qs, + logger: logger, } e.GET(formatRouterResource("/quote"), handler.GetOptimalQuote) e.GET(formatRouterResource("/routes"), handler.GetCandidateRoutes) @@ -143,6 +145,25 @@ func (a *RouterHandler) GetOptimalQuote(c echo.Context) (err error) { span.SetAttributes(attribute.Stringer("token_out", quote.GetAmountOut())) span.SetAttributes(attribute.Stringer("price_impact", quote.GetPriceImpact())) + // Simulate quote if applicable. + // Note: only single routes (non-splits) are supported for simulation. + // Additionally, the functionality is triggerred by the user providing a simulator address. + // Only "out given in" swap method is supported for simulation. Thus, we also check for tokenOutDenom being set. + if req.SingleRoute && req.SimulatorAddress != "" && req.SwapMethod() == domain.TokenSwapMethodExactIn { + simulatorAddress := c.Get("simulatorAddress").(string) + + gasUsed, feeCoin, err := a.QuoteSimulator.SimulateQuote(ctx, quote, req.SlippageToleranceMultiplier, simulatorAddress) + if err != nil { + return c.JSON(domain.GetStatusCode(err), domain.ResponseError{Message: err.Error()}) + } + + // Set the quote price info. + quote.SetQuotePriceInfo(&domain.QuotePriceInfo{ + AdjustedGasUsed: gasUsed, + FeeCoin: feeCoin, + }) + } + return c.JSON(http.StatusOK, quote) } diff --git a/router/types/get_quote_request.go b/router/types/get_quote_request.go index b05f3fad0..d7f93f715 100644 --- a/router/types/get_quote_request.go +++ b/router/types/get_quote_request.go @@ -1,6 +1,9 @@ package types import ( + "fmt" + + "github.com/osmosis-labs/osmosis/osmomath" "github.com/osmosis-labs/sqs/domain" sdk "github.com/cosmos/cosmos-sdk/types" @@ -9,13 +12,15 @@ import ( // GetQuoteRequest represents swap quote request for the /router/quote endpoint. type GetQuoteRequest struct { - TokenIn *sdk.Coin - TokenOutDenom string - TokenOut *sdk.Coin - TokenInDenom string - SingleRoute bool - HumanDenoms bool - ApplyExponents bool + TokenIn *sdk.Coin + TokenOutDenom string + TokenOut *sdk.Coin + TokenInDenom string + SingleRoute bool + SimulatorAddress string + SlippageToleranceMultiplier osmomath.Dec + HumanDenoms bool + ApplyExponents bool } // UnmarshalHTTPRequest unmarshals the HTTP request to GetQuoteRequest. @@ -51,9 +56,54 @@ func (r *GetQuoteRequest) UnmarshalHTTPRequest(c echo.Context) error { r.TokenInDenom = c.QueryParam("tokenInDenom") r.TokenOutDenom = c.QueryParam("tokenOutDenom") + simulatorAddress := c.QueryParam("simulatorAddress") + slippageToleranceStr := c.QueryParam("simulattionSlippageTolerance") + if err != nil { + return err + } + + slippageToleranceDec, err := validateSimulationParams(r.SwapMethod(), simulatorAddress, slippageToleranceStr) + if err != nil { + return err + } + + r.SimulatorAddress = simulatorAddress + r.SlippageToleranceMultiplier = slippageToleranceDec + return nil } +func validateSimulationParams(swapMethod domain.TokenSwapMethod, simulatorAddress string, slippageToleranceStr string) (osmomath.Dec, error) { + if simulatorAddress != "" { + _, err := sdk.ValAddressFromBech32(simulatorAddress) + if err != nil { + return osmomath.Dec{}, fmt.Errorf("simulator address is not valid: %w", err) + } + + // Validate that simulation is only requested for "out given in" swap method. + if swapMethod == domain.TokenSwapMethodExactIn { + return osmomath.Dec{}, fmt.Errorf("only 'out given in' swap method is supported for simulation") + } + + if slippageToleranceStr == "" { + return osmomath.Dec{}, fmt.Errorf("slippage tolerance is required for simulation") + } + + slippageTolerance, err := osmomath.NewDecFromStr(slippageToleranceStr) + if err != nil { + return osmomath.Dec{}, fmt.Errorf("slippage tolerance is not valid: %w", err) + } + + return slippageTolerance, nil + } else { + if slippageToleranceStr != "" { + return osmomath.Dec{}, fmt.Errorf("slippage tolerance is not supported without simulator address") + } + } + + return osmomath.Dec{}, nil +} + // SwapMethod returns the swap method of the request. // Request may contain data for both swap methods, only one of them should be specified, otherwise it's invalid. func (r *GetQuoteRequest) SwapMethod() domain.TokenSwapMethod { diff --git a/router/usecase/quote_out_given_in.go b/router/usecase/quote_out_given_in.go index 0f1560afc..cfd47fc73 100644 --- a/router/usecase/quote_out_given_in.go +++ b/router/usecase/quote_out_given_in.go @@ -42,6 +42,8 @@ type quoteExactAmountIn struct { EffectiveFee osmomath.Dec "json:\"effective_fee\"" PriceImpact osmomath.Dec "json:\"price_impact\"" InBaseOutQuoteSpotPrice osmomath.Dec "json:\"in_base_out_quote_spot_price\"" + + priceInfo *domain.QuotePriceInfo `json:"price_info,omitempty"` } // PrepareResult implements domain.Quote. @@ -151,3 +153,8 @@ func (q *quoteExactAmountIn) GetPriceImpact() osmomath.Dec { func (q *quoteExactAmountIn) GetInBaseOutQuoteSpotPrice() osmomath.Dec { return q.InBaseOutQuoteSpotPrice } + +// SetQuotePriceInfo implements domain.Quote. +func (q *quoteExactAmountIn) SetQuotePriceInfo(info *domain.QuotePriceInfo) { + q.priceInfo = info +}