From f65ed41017a5b27d0a8a367fe8a9593d3b73eea7 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 4 Nov 2024 09:36:05 -0800 Subject: [PATCH] feat: simulate swap as part of quotes (#547) * feat: simulate swap as part of quotes * try adding e2e test * updates * lint * fix test * swagger * attempt fix grpc wiring * fix e2e sim test * updates (cherry picked from commit f2931d81734e08ece95fc5e5641ce4ec8c08c302) # Conflicts: # domain/cosmos/tx/msg_simulator.go # domain/cosmos/tx/msg_simulator_test.go # domain/mocks/msg_simulator_mock.go # domain/passthrough/passthrough_grpc_client.go --- app/sidecar_query_server.go | 17 +- docs/docs.go | 20 +- docs/swagger.json | 20 +- docs/swagger.yaml | 16 +- domain/cosmos/tx/msg_simulator.go | 183 +++++++++++++ domain/cosmos/tx/msg_simulator_test.go | 250 ++++++++++++++++++ domain/mocks/msg_simulator_mock.go | 82 ++++++ domain/mocks/passthrough_grpc_client_mock.go | 2 +- domain/mocks/quote_mock.go | 75 ++++++ domain/mocks/quote_simulator_mock.go | 23 ++ domain/mocks/route_mock.go | 20 ++ domain/passthrough/passthrough_grpc_client.go | 18 +- domain/quote_simulator.go | 23 ++ domain/router.go | 3 + .../fillbot/context/block/block_context.go | 2 +- quotesimulator/quote_simulator.go | 82 ++++++ quotesimulator/quote_simulator_test.go | 131 +++++++++ router/delivery/http/router_handler.go | 42 ++- router/delivery/http/router_handler_test.go | 31 +++ router/types/export_test.go | 10 + router/types/get_quote_request.go | 68 ++++- router/types/get_quote_request_test.go | 80 ++++++ router/usecase/quote_out_given_in.go | 18 +- .../quote_amount_in_response_simulated.json | 57 ++++ tests/quote.py | 12 +- tests/quote_response.py | 4 +- tests/sqs_service.py | 4 +- tests/test_router_quote_out_given_in.py | 24 +- 28 files changed, 1269 insertions(+), 48 deletions(-) create mode 100644 domain/cosmos/tx/msg_simulator.go create mode 100644 domain/cosmos/tx/msg_simulator_test.go create mode 100644 domain/mocks/msg_simulator_mock.go create mode 100644 domain/mocks/quote_mock.go create mode 100644 domain/mocks/quote_simulator_mock.go create mode 100644 domain/quote_simulator.go create mode 100644 quotesimulator/quote_simulator.go create mode 100644 quotesimulator/quote_simulator_test.go create mode 100644 router/types/export_test.go create mode 100644 router/usecase/routertesting/parsing/quote_amount_in_response_simulated.json diff --git a/app/sidecar_query_server.go b/app/sidecar_query_server.go index 4011d3b3f..b86287c1e 100644 --- a/app/sidecar_query_server.go +++ b/app/sidecar_query_server.go @@ -20,12 +20,16 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "github.com/osmosis-labs/osmosis/v26/app" + txfeestypes "github.com/osmosis-labs/osmosis/v26/x/txfees/types" + "github.com/osmosis-labs/sqs/domain/cosmos/auth/types" ingestrpcdelivry "github.com/osmosis-labs/sqs/ingest/delivery/grpc" ingestusecase "github.com/osmosis-labs/sqs/ingest/usecase" orderbookclaimbot "github.com/osmosis-labs/sqs/ingest/usecase/plugins/orderbook/claimbot" orderbookfillbot "github.com/osmosis-labs/sqs/ingest/usecase/plugins/orderbook/fillbot" orderbookrepository "github.com/osmosis-labs/sqs/orderbook/repository" orderbookusecase "github.com/osmosis-labs/sqs/orderbook/usecase" + "github.com/osmosis-labs/sqs/quotesimulator" "github.com/osmosis-labs/sqs/sqsutil/datafetchers" chaininforepo "github.com/osmosis-labs/sqs/chaininfo/repository" @@ -43,6 +47,7 @@ import ( "github.com/osmosis-labs/sqs/domain" "github.com/osmosis-labs/sqs/domain/cache" + "github.com/osmosis-labs/sqs/domain/cosmos/tx" "github.com/osmosis-labs/sqs/domain/keyring" "github.com/osmosis-labs/sqs/domain/mvc" orderbookgrpcclientdomain "github.com/osmosis-labs/sqs/domain/orderbook/grpcclient" @@ -210,7 +215,17 @@ func NewSideCarQueryServer(appCodec codec.Codec, config domain.Config, logger lo if err := tokenshttpdelivery.NewTokensHandler(e, *config.Pricing, tokensUseCase, pricingSimpleRouterUsecase, logger); err != nil { return nil, err } - routerHttpDelivery.NewRouterHandler(e, routerUsecase, tokensUseCase, logger) + + grpcClient := passthroughGRPCClient.GetChainGRPCClient() + gasCalculator := tx.NewGasCalculator(grpcClient, tx.CalculateGas) + quoteSimulator := quotesimulator.NewQuoteSimulator( + gasCalculator, + app.GetEncodingConfig(), + txfeestypes.NewQueryClient(grpcClient), + types.NewQueryClient(grpcClient), + config.ChainID, + ) + routerHttpDelivery.NewRouterHandler(e, routerUsecase, tokensUseCase, quoteSimulator, logger) // Create a Numia HTTP client passthroughConfig := config.Passthrough diff --git a/docs/docs.go b/docs/docs.go index ca7dc4dd3..b0c539691 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -299,6 +299,18 @@ const docTemplate = `{ "description": "Boolean flag indicating whether to apply exponents to the spot price. False by default.", "name": "applyExponents", "in": "query" + }, + { + "type": "string", + "description": "Address of the simulator to simulate the quote. If provided, the quote will be simulated.", + "name": "simulatorAddress", + "in": "query" + }, + { + "type": "string", + "description": "Slippage tolerance multiplier for the simulation. If simulatorAddress is provided, this must be provided.", + "name": "simulationSlippageTolerance", + "in": "query" } ], "responses": { @@ -514,7 +526,7 @@ const docTemplate = `{ "type": "object", "properties": { "amount": { - "$ref": "#/definitions/types.Int" + "$ref": "#/definitions/math.Int" }, "denom": { "type": "string" @@ -663,6 +675,9 @@ const docTemplate = `{ } } }, + "math.Int": { + "type": "object" + }, "sqsdomain.CandidatePool": { "type": "object", "properties": { @@ -707,9 +722,6 @@ const docTemplate = `{ } } } - }, - "types.Int": { - "type": "object" } } }` diff --git a/docs/swagger.json b/docs/swagger.json index 0114eaff8..00a6aa56b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -290,6 +290,18 @@ "description": "Boolean flag indicating whether to apply exponents to the spot price. False by default.", "name": "applyExponents", "in": "query" + }, + { + "type": "string", + "description": "Address of the simulator to simulate the quote. If provided, the quote will be simulated.", + "name": "simulatorAddress", + "in": "query" + }, + { + "type": "string", + "description": "Slippage tolerance multiplier for the simulation. If simulatorAddress is provided, this must be provided.", + "name": "simulationSlippageTolerance", + "in": "query" } ], "responses": { @@ -505,7 +517,7 @@ "type": "object", "properties": { "amount": { - "$ref": "#/definitions/types.Int" + "$ref": "#/definitions/math.Int" }, "denom": { "type": "string" @@ -654,6 +666,9 @@ } } }, + "math.Int": { + "type": "object" + }, "sqsdomain.CandidatePool": { "type": "object", "properties": { @@ -698,9 +713,6 @@ } } } - }, - "types.Int": { - "type": "object" } } } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index fc8dfcb01..2c581e93a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -38,7 +38,7 @@ definitions: github_com_cosmos_cosmos-sdk_types.Coin: properties: amount: - $ref: '#/definitions/types.Int' + $ref: '#/definitions/math.Int' denom: type: string type: object @@ -142,6 +142,8 @@ definitions: $ref: '#/definitions/github_com_osmosis-labs_sqs_domain_orderbook.LimitOrder' type: array type: object + math.Int: + type: object sqsdomain.CandidatePool: properties: id: @@ -171,8 +173,6 @@ definitions: type: object type: object type: object - types.Int: - type: object info: contact: {} title: Osmosis Sidecar Query Server Example API @@ -407,6 +407,16 @@ paths: in: query name: applyExponents type: boolean + - description: Address of the simulator to simulate the quote. If provided, + the quote will be simulated. + in: query + name: simulatorAddress + type: string + - description: Slippage tolerance multiplier for the simulation. If simulatorAddress + is provided, this must be provided. + in: query + name: simulationSlippageTolerance + type: string produces: - application/json responses: diff --git a/domain/cosmos/tx/msg_simulator.go b/domain/cosmos/tx/msg_simulator.go new file mode 100644 index 000000000..9a88df574 --- /dev/null +++ b/domain/cosmos/tx/msg_simulator.go @@ -0,0 +1,183 @@ +package tx + +import ( + "context" + + cosmosclient "github.com/cosmos/cosmos-sdk/client" + txclient "github.com/cosmos/cosmos-sdk/client/tx" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + sdk "github.com/cosmos/cosmos-sdk/types" + txtypes "github.com/cosmos/cosmos-sdk/types/tx" + signingtypes "github.com/cosmos/cosmos-sdk/types/tx/signing" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/osmosis-labs/osmosis/v26/app/params" + txfeestypes "github.com/osmosis-labs/osmosis/v26/x/txfees/types" + "github.com/osmosis-labs/sqs/domain/keyring" + + gogogrpc "github.com/cosmos/gogoproto/grpc" +) + +// MsgSimulator is an interface for calculating gas for a transaction. +type MsgSimulator interface { + BuildTx( + ctx context.Context, + keyring keyring.Keyring, + txfeesClient txfeestypes.QueryClient, + encodingConfig params.EncodingConfig, + account *authtypes.BaseAccount, + chainID string, + msg ...sdk.Msg, + ) (cosmosclient.TxBuilder, error) + + // SimulateMsgs simulates the execution of the given messages and returns the simulation response, + // adjusted gas used, and any error encountered. It uses the provided gRPC client, encoding config, + // account details, and chain ID to create a transaction factory for the simulation. + SimulateMsgs( + encodingConfig cosmosclient.TxConfig, + account *authtypes.BaseAccount, + 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. +func NewGasCalculator(clientCtx gogogrpc.ClientConn, calculateGas CalculateGasFn) MsgSimulator { + return &txGasCalulator{ + clientCtx: clientCtx, + calculateGas: calculateGas, + } +} + +// CalculateGasFn is a function type that calculates the gas for a transaction. +type CalculateGasFn func(clientCtx gogogrpc.ClientConn, txf txclient.Factory, msgs ...sdk.Msg) (*txtypes.SimulateResponse, uint64, error) + +// txGasCalulator is a GasCalculator implementation that uses simulated transactions to calculate gas. +type txGasCalulator struct { + clientCtx gogogrpc.ClientConn + calculateGas CalculateGasFn +} + +// BuildTx constructs a transaction using the provided parameters and messages. +// Returns a TxBuilder and any error encountered. +func (c *txGasCalulator) BuildTx( + ctx context.Context, + keyring keyring.Keyring, + txfeesClient txfeestypes.QueryClient, + encodingConfig params.EncodingConfig, + account *authtypes.BaseAccount, + chainID string, + msg ...sdk.Msg, +) (cosmosclient.TxBuilder, error) { + key := keyring.GetKey() + privKey := &secp256k1.PrivKey{Key: key.Bytes()} + + // Create and sign the transaction + txBuilder := encodingConfig.TxConfig.NewTxBuilder() + + err := txBuilder.SetMsgs(msg...) + if err != nil { + return nil, err + } + + gasAdjusted, feecoin, err := c.PriceMsgs(ctx, txfeesClient, encodingConfig.TxConfig, account, chainID, msg...) + if err != nil { + return nil, err + } + + txBuilder.SetGasLimit(gasAdjusted) + txBuilder.SetFeeAmount(sdk.Coins{feecoin}) + + sigV2 := BuildSignatures(privKey.PubKey(), nil, account.Sequence) + err = txBuilder.SetSignatures(sigV2) + if err != nil { + return nil, err + } + + signerData := BuildSignerData(chainID, account.AccountNumber, account.Sequence) + + signed, err := txclient.SignWithPrivKey( + ctx, + signingtypes.SignMode_SIGN_MODE_DIRECT, signerData, + txBuilder, privKey, encodingConfig.TxConfig, account.Sequence) + if err != nil { + return nil, err + } + + err = txBuilder.SetSignatures(signed) + if err != nil { + return nil, err + } + + return txBuilder, nil +} + +// SimulateMsgs implements MsgSimulator. +func (c *txGasCalulator) SimulateMsgs(encodingConfig cosmosclient.TxConfig, account *authtypes.BaseAccount, chainID string, msgs []sdk.Msg) (*txtypes.SimulateResponse, uint64, error) { + txFactory := txclient.Factory{} + txFactory = txFactory.WithTxConfig(encodingConfig) + txFactory = txFactory.WithAccountNumber(account.AccountNumber) + txFactory = txFactory.WithSequence(account.Sequence) + txFactory = txFactory.WithChainID(chainID) + txFactory = txFactory.WithGasAdjustment(1.05) + + // Estimate transaction + gasResult, adjustedGasUsed, err := c.calculateGas( + c.clientCtx, + txFactory, + msgs..., + ) + if err != nil { + return nil, adjustedGasUsed, err + } + + 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, + txf txclient.Factory, + msgs ...sdk.Msg, +) (*txtypes.SimulateResponse, uint64, error) { + gasResult, adjustedGasUsed, err := txclient.CalculateGas( + clientCtx, + txf, + msgs..., + ) + if err != nil { + return nil, adjustedGasUsed, err + } + + return gasResult, adjustedGasUsed, nil +} diff --git a/domain/cosmos/tx/msg_simulator_test.go b/domain/cosmos/tx/msg_simulator_test.go new file mode 100644 index 000000000..c18ac22af --- /dev/null +++ b/domain/cosmos/tx/msg_simulator_test.go @@ -0,0 +1,250 @@ +package tx_test + +import ( + "context" + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + txtypes "github.com/cosmos/cosmos-sdk/types/tx" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/osmosis-labs/osmosis/osmomath" + "github.com/osmosis-labs/sqs/domain/cosmos/tx" + "github.com/osmosis-labs/sqs/domain/mocks" + "github.com/stretchr/testify/assert" +) + +const ( + testChainID = "test-chain" + testKey = "6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159" + testDenom = "eth" + testBaseFee = "0.1" + testGasUsed = uint64(50) + testAmount = int64(5) +) + +var ( + testAccount = &authtypes.BaseAccount{ + Sequence: 13, + AccountNumber: 1, + } + testMsg = newMsg("sender", "contract", `{"payload": "hello contract"}`) + testTxJSON = []byte(`{"body":{"messages":[{"@type":"/cosmwasm.wasm.v1.MsgExecuteContract","sender":"sender","contract":"contract","msg":{"payload":"hello contract"},"funds":[]}],"memo":"","timeout_height":"0","extension_options":[],"non_critical_extension_options":[]},"auth_info":{"signer_infos":[{"public_key":{"@type":"/cosmos.crypto.secp256k1.PubKey","key":"A+9dbfKKCHgfmiV2XUWelqidYzZhHR+KtNMvcSzWjdPQ"},"mode_info":{"single":{"mode":"SIGN_MODE_DIRECT"}},"sequence":"13"}],"fee":{"amount":[{"denom":"eth","amount":"5"}],"gas_limit":"50","payer":"","granter":""},"tip":null},"signatures":["aRlC8F2MnDA50tNNTJUk7zPvH/xc5c3Av+yaGQEiU0l0AXJxUdzOUxWHiC74D9ltvbsk0HzWbb+2uetCjdQdfA=="]}`) +) + +func TestSimulateMsgs(t *testing.T) { + tests := []struct { + name string + account *authtypes.BaseAccount + chainID string + msgs []sdk.Msg + setupMocks func(calculator mocks.GetCalculateGasMock) tx.CalculateGasFn + expectedSimulateResponse *txtypes.SimulateResponse + expectedGas uint64 + expectedError error + }{ + { + name: "Successful simulation", + account: testAccount, + chainID: testChainID, + msgs: []sdk.Msg{testMsg}, + setupMocks: func(calculator mocks.GetCalculateGasMock) tx.CalculateGasFn { + return calculator(&txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, testGasUsed, nil) + }, + expectedSimulateResponse: &txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, + expectedGas: testGasUsed, + expectedError: nil, + }, + { + name: "Simulation error", + account: &authtypes.BaseAccount{AccountNumber: 2, Sequence: 2}, + chainID: testChainID, + msgs: []sdk.Msg{}, + setupMocks: func(calculator mocks.GetCalculateGasMock) tx.CalculateGasFn { + return calculator(&txtypes.SimulateResponse{}, testGasUsed, assert.AnError) + }, + expectedSimulateResponse: nil, + expectedGas: testGasUsed, + expectedError: assert.AnError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + calculateGasFnMock := tt.setupMocks(mocks.DefaultGetCalculateGasMock) + gasCalculator := tx.NewGasCalculator(nil, calculateGasFnMock) + + result, gas, err := gasCalculator.SimulateMsgs( + encodingConfig.TxConfig, + tt.account, + tt.chainID, + tt.msgs, + ) + + assert.Equal(t, tt.expectedSimulateResponse, result) + assert.Equal(t, tt.expectedGas, gas) + if tt.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tt.expectedError, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestBuildTx(t *testing.T) { + testCases := []struct { + name string + setupMocks func(calculator mocks.GetCalculateGasMock, txFeesClient *mocks.TxFeesQueryClient, keyring *mocks.Keyring) tx.CalculateGasFn + account *authtypes.BaseAccount + chainID string + msgs []sdk.Msg + expectedJSON []byte + expectedError bool + }{ + { + name: "Valid transaction", + setupMocks: func(calculator mocks.GetCalculateGasMock, txFeesClient *mocks.TxFeesQueryClient, keyring *mocks.Keyring) tx.CalculateGasFn { + keyring.WithGetKey(testKey) + txFeesClient.WithBaseDenom(testDenom, nil) + txFeesClient.WithGetEipBaseFee(testBaseFee, nil) + + return calculator(&txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, testGasUsed, nil) + }, + account: testAccount, + chainID: testChainID, + msgs: []sdk.Msg{testMsg}, + expectedJSON: testTxJSON, + expectedError: false, + }, + { + name: "Error building transaction", + setupMocks: func(calculator mocks.GetCalculateGasMock, txFeesClient *mocks.TxFeesQueryClient, keyring *mocks.Keyring) tx.CalculateGasFn { + keyring.WithGetKey(testKey) + return calculator(&txtypes.SimulateResponse{}, testGasUsed, assert.AnError) + }, + account: &authtypes.BaseAccount{ + Sequence: 8, + AccountNumber: 51, + }, + expectedError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + txFeesClient := mocks.TxFeesQueryClient{} + keyring := mocks.Keyring{} + + calculateGasFnMock := tc.setupMocks(mocks.DefaultGetCalculateGasMock, &txFeesClient, &keyring) + msgSimulator := tx.NewGasCalculator(nil, calculateGasFnMock) + + txBuilder, err := msgSimulator.BuildTx( + context.Background(), + &keyring, + &txFeesClient, + encodingConfig, + tc.account, + tc.chainID, + tc.msgs..., + ) + + if tc.expectedError { + assert.Error(t, err) + assert.Nil(t, txBuilder) + } else { + assert.NoError(t, err) + assert.NotNil(t, txBuilder) + + txJSONBytes, err := encodingConfig.TxConfig.TxJSONEncoder()(txBuilder.GetTx()) + assert.NoError(t, err) + assert.Equal(t, string(tc.expectedJSON), string(txJSONBytes)) + } + }) + } +} + +func TestPriceMsgs(t *testing.T) { + testCases := []struct { + name string + setupMocks func(calculator mocks.GetCalculateGasMock, txFeesClient *mocks.TxFeesQueryClient, keyring *mocks.Keyring) tx.CalculateGasFn + account *authtypes.BaseAccount + chainID string + msgs []sdk.Msg + expectedGas uint64 + expectedFeeCoin sdk.Coin + expectedError bool + }{ + { + name: "Valid transaction", + setupMocks: func(calculator mocks.GetCalculateGasMock, txFeesClient *mocks.TxFeesQueryClient, keyring *mocks.Keyring) tx.CalculateGasFn { + keyring.WithGetKey(testKey) + txFeesClient.WithBaseDenom(testDenom, nil) + txFeesClient.WithGetEipBaseFee(testBaseFee, nil) + + return calculator(&txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, testGasUsed, nil) + }, + account: testAccount, + chainID: testChainID, + msgs: []sdk.Msg{testMsg}, + expectedGas: testGasUsed, + expectedFeeCoin: sdk.Coin{Denom: testDenom, Amount: osmomath.NewInt(testAmount)}, + expectedError: false, + }, + { + name: "Error building transaction", + setupMocks: func(calculator mocks.GetCalculateGasMock, txFeesClient *mocks.TxFeesQueryClient, keyring *mocks.Keyring) tx.CalculateGasFn { + keyring.WithGetKey(testKey) + return calculator(&txtypes.SimulateResponse{}, testGasUsed, assert.AnError) + }, + account: &authtypes.BaseAccount{ + Sequence: 8, + AccountNumber: 51, + }, + expectedError: true, + }, + { + name: "Error calculating fee coin", + setupMocks: func(calculator mocks.GetCalculateGasMock, txFeesClient *mocks.TxFeesQueryClient, keyring *mocks.Keyring) tx.CalculateGasFn { + keyring.WithGetKey(testKey) + txFeesClient.WithBaseDenom(testDenom, assert.AnError) + + return calculator(&txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, testGasUsed, nil) + }, + account: testAccount, + chainID: testChainID, + msgs: []sdk.Msg{testMsg}, + expectedGas: testGasUsed, + expectedError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + txFeesClient := mocks.TxFeesQueryClient{} + keyring := mocks.Keyring{} + + calculateGasFnMock := tc.setupMocks(mocks.DefaultGetCalculateGasMock, &txFeesClient, &keyring) + msgSimulator := tx.NewGasCalculator(nil, calculateGasFnMock) + + gasUsed, feeCoin, err := msgSimulator.PriceMsgs( + context.Background(), + &txFeesClient, + encodingConfig.TxConfig, + tc.account, + tc.chainID, + tc.msgs..., + ) + + if tc.expectedError { + assert.Error(t, err) + assert.Equal(t, uint64(0), gasUsed) + assert.Equal(t, sdk.Coin{}, feeCoin) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedGas, gasUsed) + assert.Equal(t, tc.expectedFeeCoin, feeCoin) + } + }) + } +} diff --git a/domain/mocks/msg_simulator_mock.go b/domain/mocks/msg_simulator_mock.go new file mode 100644 index 000000000..86c014d5f --- /dev/null +++ b/domain/mocks/msg_simulator_mock.go @@ -0,0 +1,82 @@ +package mocks + +import ( + "context" + + "github.com/cosmos/cosmos-sdk/client" + sdk "github.com/cosmos/cosmos-sdk/types" + txtypes "github.com/cosmos/cosmos-sdk/types/tx" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/osmosis-labs/osmosis/v26/app/params" + txfeestypes "github.com/osmosis-labs/osmosis/v26/x/txfees/types" + sqstx "github.com/osmosis-labs/sqs/domain/cosmos/tx" + "github.com/osmosis-labs/sqs/domain/keyring" +) + +type MsgSimulatorMock struct { + BuildTxFn func( + ctx context.Context, + keyring keyring.Keyring, + txfeesClient txfeestypes.QueryClient, + encodingConfig params.EncodingConfig, + account *authtypes.BaseAccount, + chainID string, + msg ...sdk.Msg, + ) (client.TxBuilder, error) + + SimulateMsgsFn func( + encodingConfig client.TxConfig, + account *authtypes.BaseAccount, + 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{} + +func (m *MsgSimulatorMock) BuildTx(ctx context.Context, + keyring keyring.Keyring, + txfeesClient txfeestypes.QueryClient, + encodingConfig params.EncodingConfig, + account *authtypes.BaseAccount, + chainID string, + msg ...sdk.Msg, +) (client.TxBuilder, error) { + if m.BuildTxFn != nil { + return m.BuildTxFn(ctx, keyring, txfeesClient, encodingConfig, account, chainID, msg...) + } + panic("BuildTxFn not implemented") +} + +func (m *MsgSimulatorMock) SimulateMsgs( + encodingConfig client.TxConfig, + account *authtypes.BaseAccount, + chainID string, + msgs []sdk.Msg, +) (*txtypes.SimulateResponse, uint64, error) { + if m.SimulateMsgsFn != nil { + return m.SimulateMsgsFn(encodingConfig, account, chainID, msgs) + } + 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/mocks/passthrough_grpc_client_mock.go b/domain/mocks/passthrough_grpc_client_mock.go index 773d22da1..5d7431665 100644 --- a/domain/mocks/passthrough_grpc_client_mock.go +++ b/domain/mocks/passthrough_grpc_client_mock.go @@ -20,7 +20,7 @@ type PassthroughGRPCClientMock struct { } // GetChainGRPCClient implements passthroughdomain.PassthroughGRPCClient. -func (p *PassthroughGRPCClientMock) GetChainGRPCClient() *grpc.ClientConn { +func (p *PassthroughGRPCClientMock) GetChainGRPCClient() grpc.ClientConnInterface { panic("unimplemented") } diff --git a/domain/mocks/quote_mock.go b/domain/mocks/quote_mock.go new file mode 100644 index 000000000..4c682d271 --- /dev/null +++ b/domain/mocks/quote_mock.go @@ -0,0 +1,75 @@ +package mocks + +import ( + "context" + + "cosmossdk.io/math" + "github.com/cosmos/cosmos-sdk/types" + "github.com/osmosis-labs/sqs/domain" + "github.com/osmosis-labs/sqs/log" +) + +type MockQuote struct { + GetAmountInFunc func() types.Coin + GetAmountOutFunc func() math.Int + GetRouteFunc func() []domain.SplitRoute +} + +// GetAmountIn implements domain.Quote. +func (m *MockQuote) GetAmountIn() types.Coin { + if m.GetAmountInFunc != nil { + return m.GetAmountInFunc() + } + + panic("unimplemented") +} + +// GetAmountOut implements domain.Quote. +func (m *MockQuote) GetAmountOut() math.Int { + if m.GetAmountOutFunc != nil { + return m.GetAmountOutFunc() + } + + panic("unimplemented") +} + +// GetEffectiveFee implements domain.Quote. +func (m *MockQuote) GetEffectiveFee() math.LegacyDec { + panic("unimplemented") +} + +// GetInBaseOutQuoteSpotPrice implements domain.Quote. +func (m *MockQuote) GetInBaseOutQuoteSpotPrice() math.LegacyDec { + panic("unimplemented") +} + +// GetPriceImpact implements domain.Quote. +func (m *MockQuote) GetPriceImpact() math.LegacyDec { + panic("unimplemented") +} + +// GetRoute implements domain.Quote. +func (m *MockQuote) GetRoute() []domain.SplitRoute { + if m.GetRouteFunc != nil { + return m.GetRouteFunc() + } + + panic("unimplemented") +} + +// PrepareResult implements domain.Quote. +func (m *MockQuote) PrepareResult(ctx context.Context, scalingFactor math.LegacyDec, logger log.Logger) ([]domain.SplitRoute, math.LegacyDec, error) { + panic("unimplemented") +} + +// SetQuotePriceInfo implements domain.Quote. +func (m *MockQuote) SetQuotePriceInfo(info *domain.QuotePriceInfo) { + panic("unimplemented") +} + +// String implements domain.Quote. +func (m *MockQuote) String() string { + panic("unimplemented") +} + +var _ domain.Quote = &MockQuote{} diff --git a/domain/mocks/quote_simulator_mock.go b/domain/mocks/quote_simulator_mock.go new file mode 100644 index 000000000..355e0499b --- /dev/null +++ b/domain/mocks/quote_simulator_mock.go @@ -0,0 +1,23 @@ +package mocks + +import ( + "context" + + "cosmossdk.io/math" + "github.com/cosmos/cosmos-sdk/types" + "github.com/osmosis-labs/sqs/domain" +) + +type QuoteSimulatorMock struct { + SimulateQuoteFn func(ctx context.Context, quote domain.Quote, slippageToleranceMultiplier math.LegacyDec, simulatorAddress string) (uint64, types.Coin, error) +} + +// SimulateQuote implements domain.QuoteSimulator. +func (q *QuoteSimulatorMock) SimulateQuote(ctx context.Context, quote domain.Quote, slippageToleranceMultiplier math.LegacyDec, simulatorAddress string) (uint64, types.Coin, error) { + if q.SimulateQuoteFn != nil { + return q.SimulateQuoteFn(ctx, quote, slippageToleranceMultiplier, simulatorAddress) + } + panic("SimulateQuoteFn not implemented") +} + +var _ domain.QuoteSimulator = &QuoteSimulatorMock{} diff --git a/domain/mocks/route_mock.go b/domain/mocks/route_mock.go index 8c9bcbc36..e03727a34 100644 --- a/domain/mocks/route_mock.go +++ b/domain/mocks/route_mock.go @@ -17,6 +17,9 @@ type RouteMock struct { GetTokenInDenomFunc func() string PrepareResultPoolsFunc func(ctx context.Context, tokenIn types.Coin, logger log.Logger) ([]domain.RoutablePool, math.LegacyDec, math.LegacyDec, error) StringFunc func() string + + GetAmountInFunc func() math.Int + GetAmountOutFunc func() math.Int } // CalculateTokenOutByTokenIn implements domain.Route. @@ -82,4 +85,21 @@ func (r *RouteMock) String() string { panic("unimplemented") } +func (r *RouteMock) GetAmountIn() math.Int { + if r.GetAmountInFunc != nil { + return r.GetAmountInFunc() + } + + panic("unimplemented") +} + +func (r *RouteMock) GetAmountOut() math.Int { + if r.GetAmountOutFunc != nil { + return r.GetAmountOutFunc() + } + + panic("unimplemented") +} + var _ domain.Route = &RouteMock{} +var _ domain.SplitRoute = &RouteMock{} diff --git a/domain/passthrough/passthrough_grpc_client.go b/domain/passthrough/passthrough_grpc_client.go index e4d1337b2..e56dfb9d6 100644 --- a/domain/passthrough/passthrough_grpc_client.go +++ b/domain/passthrough/passthrough_grpc_client.go @@ -9,11 +9,16 @@ import ( distribution "github.com/cosmos/cosmos-sdk/x/distribution/types" staking "github.com/cosmos/cosmos-sdk/x/staking/types" math "github.com/osmosis-labs/osmosis/osmomath" +<<<<<<< HEAD concentratedLiquidity "github.com/osmosis-labs/osmosis/v27/x/concentrated-liquidity/client/queryproto" lockup "github.com/osmosis-labs/osmosis/v27/x/lockup/types" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" +======= + concentratedLiquidity "github.com/osmosis-labs/osmosis/v26/x/concentrated-liquidity/client/queryproto" + lockup "github.com/osmosis-labs/osmosis/v26/x/lockup/types" + polarisgrpc "github.com/osmosis-labs/sqs/delivery/grpc" +>>>>>>> f2931d8 (feat: simulate swap as part of quotes (#547)) "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" ) // PassthroughGRPCClient represents the GRPC client for the passthrough module to query the chain. @@ -40,7 +45,7 @@ type PassthroughGRPCClient interface { // DelegationTotalRewards returns the total unclaimed staking rewards accrued of the user with the given address. DelegationRewards(ctx context.Context, address string) (sdk.Coins, error) - GetChainGRPCClient() *grpc.ClientConn + GetChainGRPCClient() grpc.ClientConnInterface } type PassthroughFetchFn func(context.Context, string) (sdk.Coins, error) @@ -57,7 +62,7 @@ type passthroughGRPCClient struct { concentratedLiquidityQueryClient concentratedLiquidity.QueryClient distributionClient distribution.QueryClient - chainGRPCClient *grpc.ClientConn + chainGRPCClient grpc.ClientConnInterface } const ( @@ -69,10 +74,7 @@ var ( ) func NewPassthroughGRPCClient(grpcURI string) (PassthroughGRPCClient, error) { - grpcClient, err := grpc.NewClient(grpcURI, - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithStatsHandler(otelgrpc.NewClientHandler()), - ) + grpcClient, err := polarisgrpc.NewClient(grpcURI) if err != nil { return nil, err } @@ -199,7 +201,7 @@ func (p *passthroughGRPCClient) DelegationRewards(ctx context.Context, address s } // GetChainGRPCClient implements PassthroughGRPCClient. -func (p *passthroughGRPCClient) GetChainGRPCClient() *grpc.ClientConn { +func (p *passthroughGRPCClient) GetChainGRPCClient() grpc.ClientConnInterface { return p.chainGRPCClient } 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/ingest/usecase/plugins/orderbook/fillbot/context/block/block_context.go b/ingest/usecase/plugins/orderbook/fillbot/context/block/block_context.go index 0c90a4692..a49b2af35 100644 --- a/ingest/usecase/plugins/orderbook/fillbot/context/block/block_context.go +++ b/ingest/usecase/plugins/orderbook/fillbot/context/block/block_context.go @@ -58,7 +58,7 @@ type BlockGasPrice struct { var _ BlockCtxI = &blockContext{} // New creates a new block context. -func New(ctx context.Context, chainGRPCClient *grpc.ClientConn, uniqueDenoms []string, orderBookDenomPrices domain.PricesResult, userBalances sdk.Coins, defaultQuoteDenom string, blockHeight uint64) (*blockContext, error) { +func New(ctx context.Context, chainGRPCClient grpc.ClientConnInterface, uniqueDenoms []string, orderBookDenomPrices domain.PricesResult, userBalances sdk.Coins, defaultQuoteDenom string, blockHeight uint64) (*blockContext, error) { blockCtx := blockContext{ Context: ctx, txContext: txctx.New(), diff --git a/quotesimulator/quote_simulator.go b/quotesimulator/quote_simulator.go new file mode 100644 index 000000000..c36c33087 --- /dev/null +++ b/quotesimulator/quote_simulator.go @@ -0,0 +1,82 @@ +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" +) + +// quoteSimulator simulates a quote and returns the gas adjusted amount and the fee coin. +type quoteSimulator struct { + msgSimulator tx.MsgSimulator + encodingConfig params.EncodingConfig + txFeesClient txfeestypes.QueryClient + accountQueryClient types.QueryClient + chainID string +} + +func NewQuoteSimulator(msgSimulator tx.MsgSimulator, encodingConfig params.EncodingConfig, txFeesClient txfeestypes.QueryClient, accountQueryClient types.QueryClient, chainID string) *quoteSimulator { + return "eSimulator{ + msgSimulator: msgSimulator, + encodingConfig: encodingConfig, + txFeesClient: txFeesClient, + accountQueryClient: accountQueryClient, + chainID: chainID, + } +} + +// SimulateQuote implements domain.QuoteSimulator +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() + + // Create the pool manager route + poolManagerRoute := make([]poolmanagertypes.SwapAmountInRoute, len(poolsInRoute)) + for i, r := range poolsInRoute { + poolManagerRoute[i] = poolmanagertypes.SwapAmountInRoute{ + PoolId: r.GetId(), + TokenOutDenom: r.GetTokenOutDenom(), + } + } + + // Slippage bound from the token in and provided slippage tolerance multiplier + tokenOutAmt := quote.GetAmountOut() + slippageBound := tokenOutAmt.ToLegacyDec().Mul(slippageToleranceMultiplier).TruncateInt() + + // Create the swap message + swapMsg := &poolmanagertypes.MsgSwapExactAmountIn{ + Sender: simulatorAddress, + Routes: poolManagerRoute, + TokenIn: quote.GetAmountIn(), + TokenOutMinAmount: slippageBound, + } + + // Get the account for the simulator address + baseAccount, err := q.accountQueryClient.GetAccount(ctx, simulatorAddress) + if err != nil { + return 0, sdk.Coin{}, err + } + + // Price the message + gasAdjusted, feeCoin, err := q.msgSimulator.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/quotesimulator/quote_simulator_test.go b/quotesimulator/quote_simulator_test.go new file mode 100644 index 000000000..88935bd67 --- /dev/null +++ b/quotesimulator/quote_simulator_test.go @@ -0,0 +1,131 @@ +package quotesimulator + +import ( + "context" + "testing" + + "cosmossdk.io/math" + "github.com/stretchr/testify/assert" + + "github.com/cosmos/cosmos-sdk/client" + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/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/mocks" +) + +func TestSimulateQuote(t *testing.T) { + const ( + tokenOutDenom = "atom" + ) + + var ( + uosmoCoinIn = sdk.NewCoin("uosmo", osmomath.NewInt(1000000)) + ) + + tests := []struct { + name string + slippageToleranceMultiplier osmomath.Dec + simulatorAddress string + expectedGasAdjusted uint64 + expectedFeeCoin sdk.Coin + expectError bool + expectedErrorMsg string + }{ + { + name: "happy path", + slippageToleranceMultiplier: osmomath.OneDec(), + simulatorAddress: "osmo13t8prr8hu7hkuksnfrd25vpvvnrfxr223k59ph", + expectedGasAdjusted: 100000, + expectedFeeCoin: sdk.NewCoin("uosmo", osmomath.NewInt(10000)), + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup mocks + mockQuote := &mocks.MockQuote{ + GetAmountInFunc: func() sdk.Coin { + return uosmoCoinIn + }, + + GetAmountOutFunc: func() math.Int { + return osmomath.NewInt(200000) + }, + + GetRouteFunc: func() []domain.SplitRoute { + return []domain.SplitRoute{ + &mocks.RouteMock{ + GetAmountInFunc: func() math.Int { + return uosmoCoinIn.Amount + }, + + GetPoolsFunc: func() []domain.RoutablePool { + return []domain.RoutablePool{ + &mocks.MockRoutablePool{ + ID: 1, + }, + } + }, + + GetTokenOutDenomFunc: func() string { + return tokenOutDenom + }, + }, + } + }, + } + msgSimulator := &mocks.MsgSimulatorMock{ + PriceMsgsFn: func( + ctx context.Context, + txfeesClient txfeestypes.QueryClient, + encodingConfig client.TxConfig, + account *authtypes.BaseAccount, + chainID string, + msg ...sdk.Msg, + ) (uint64, sdk.Coin, error) { + return tt.expectedGasAdjusted, tt.expectedFeeCoin, nil + }, + } + txFeesClient := &mocks.TxFeesQueryClient{} + accountQueryClient := &mocks.AuthQueryClientMock{ + GetAccountFunc: func(ctx context.Context, address string) (*authtypes.BaseAccount, error) { + return &authtypes.BaseAccount{ + AccountNumber: 1, + }, nil + }, + } + + // Create quote simulator + simulator := NewQuoteSimulator( + msgSimulator, + params.EncodingConfig{}, + txFeesClient, + accountQueryClient, + "osmosis-1", + ) + + // System under test + gasAdjusted, feeCoin, err := simulator.SimulateQuote( + context.Background(), + mockQuote, + tt.slippageToleranceMultiplier, + tt.simulatorAddress, + ) + + // Assert results + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErrorMsg) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedGasAdjusted, gasAdjusted) + assert.Equal(t, tt.expectedFeeCoin, feeCoin) + } + }) + } +} diff --git a/router/delivery/http/router_handler.go b/router/delivery/http/router_handler.go index df9928951..702936b28 100644 --- a/router/delivery/http/router_handler.go +++ b/router/delivery/http/router_handler.go @@ -1,6 +1,7 @@ package http import ( + "fmt" "net/http" "strconv" @@ -19,9 +20,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 +37,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) @@ -68,6 +71,8 @@ func NewRouterHandler(e *echo.Echo, us mvc.RouterUsecase, tu mvc.TokensUsecase, // @Param singleRoute query bool false "Boolean flag indicating whether to return single routes (no splits). False (splits enabled) by default." // @Param humanDenoms query bool true "Boolean flag indicating whether the given denoms are human readable or not. Human denoms get converted to chain internally" // @Param applyExponents query bool false "Boolean flag indicating whether to apply exponents to the spot price. False by default." +// @Param simulatorAddress query string false "Address of the simulator to simulate the quote. If provided, the quote will be simulated." +// @Param simulationSlippageTolerance query string false "Slippage tolerance multiplier for the simulation. If simulatorAddress is provided, this must be provided." // @Success 200 {object} domain.Quote "The computed best route quote" // @Router /router/quote [get] func (a *RouterHandler) GetOptimalQuote(c echo.Context) (err error) { @@ -75,6 +80,11 @@ func (a *RouterHandler) GetOptimalQuote(c echo.Context) (err error) { span := trace.SpanFromContext(ctx) defer func() { + if r := recover(); r != nil { + // nolint:errcheck // ignore error + c.JSON(http.StatusInternalServerError, domain.ResponseError{Message: fmt.Sprintf("panic: %v", r)}) + } + if err != nil { span.RecordError(err) // nolint:errcheck // ignore error @@ -143,6 +153,24 @@ 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. + simulatorAddress := req.SimulatorAddress + if req.SingleRoute && simulatorAddress != "" && req.SwapMethod() == domain.TokenSwapMethodExactIn { + 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/delivery/http/router_handler_test.go b/router/delivery/http/router_handler_test.go index 0e2ed878f..578d977fd 100644 --- a/router/delivery/http/router_handler_test.go +++ b/router/delivery/http/router_handler_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/labstack/echo/v4" "github.com/osmosis-labs/sqs/domain" @@ -68,6 +69,36 @@ func (s *RouterHandlerSuite) TestGetOptimalQuote() { expectedStatusCode: http.StatusOK, expectedResponse: s.MustReadFile("../../usecase/routertesting/parsing/quote_amount_in_response.json"), }, + { + name: "valid exact in request (simulated)", + queryParams: map[string]string{ + "tokenIn": "1000ibc/EA1D43981D5C9A1C4AAEA9C23BB1D4FA126BA9BC7020A25E0AE4AA841EA25DC5", + "tokenOutDenom": "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4", + "singleRoute": "true", + "applyExponents": "true", + "simulatorAddress": "osmo13t8prr8hu7hkuksnfrd25vpvvnrfxr223k59ph", + "simulationSlippageTolerance": "1.01", + }, + handler: &routerdelivery.RouterHandler{ + TUsecase: &mocks.TokensUsecaseMock{ + IsValidChainDenomFunc: func(chainDenom string) bool { + return true + }, + }, + RUsecase: &mocks.RouterUsecaseMock{ + GetOptimalQuoteFunc: func(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, opts ...domain.RouterOption) (domain.Quote, error) { + return s.NewExactAmountInQuote(poolOne, poolTwo, poolThree), nil + }, + }, + QuoteSimulator: &mocks.QuoteSimulatorMock{ + SimulateQuoteFn: func(ctx context.Context, quote domain.Quote, slippageToleranceMultiplier math.LegacyDec, simulatorAddress string) (uint64, sdk.Coin, error) { + return 1_000_000, sdk.NewCoin("uosmo", math.NewInt(1000)), nil + }, + }, + }, + expectedStatusCode: http.StatusOK, + expectedResponse: s.MustReadFile("../../usecase/routertesting/parsing/quote_amount_in_response_simulated.json"), + }, { name: "valid exact out request", queryParams: map[string]string{ diff --git a/router/types/export_test.go b/router/types/export_test.go new file mode 100644 index 000000000..728c28549 --- /dev/null +++ b/router/types/export_test.go @@ -0,0 +1,10 @@ +package types + +import ( + "github.com/osmosis-labs/osmosis/osmomath" + "github.com/osmosis-labs/sqs/domain" +) + +func ValidateSimulationParams(swapMethod domain.TokenSwapMethod, simulatorAddress string, slippageToleranceStr string) (osmomath.Dec, error) { + return validateSimulationParams(swapMethod, simulatorAddress, slippageToleranceStr) +} diff --git a/router/types/get_quote_request.go b/router/types/get_quote_request.go index b05f3fad0..2894ce0e7 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,58 @@ 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("simulationSlippageTolerance") + + slippageToleranceDec, err := validateSimulationParams(r.SwapMethod(), simulatorAddress, slippageToleranceStr) + if err != nil { + return err + } + + r.SimulatorAddress = simulatorAddress + r.SlippageToleranceMultiplier = slippageToleranceDec + return nil } +// validateSimulationParams validates the simulation parameters. +// Returns error if the simulation parameters are invalid. +// Returns slippage tolerance if it's valid. +func validateSimulationParams(swapMethod domain.TokenSwapMethod, simulatorAddress string, slippageToleranceStr string) (osmomath.Dec, error) { + if simulatorAddress != "" { + _, err := sdk.AccAddressFromBech32(simulatorAddress) + if err != nil { + return osmomath.Dec{}, fmt.Errorf("simulator address is not valid: (%s) (%w)", simulatorAddress, 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) + } + + if slippageTolerance.LTE(osmomath.ZeroDec()) { + return osmomath.Dec{}, fmt.Errorf("slippage tolerance must be greater than 0") + } + + 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/types/get_quote_request_test.go b/router/types/get_quote_request_test.go index c6ebf8350..af0c79887 100644 --- a/router/types/get_quote_request_test.go +++ b/router/types/get_quote_request_test.go @@ -12,6 +12,7 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestGetQuoteRequestUnmarshal tests the UnmarshalHTTPRequest method of GetQuoteRequest. @@ -247,3 +248,82 @@ func TestGetQuoteRequestValidate(t *testing.T) { }) } } + +func TestValidateSimulationParams(t *testing.T) { + tests := []struct { + name string + swapMethod domain.TokenSwapMethod + simulatorAddress string + slippageToleranceStr string + want osmomath.Dec + + expectedError bool + }{ + { + name: "valid simulation params", + swapMethod: domain.TokenSwapMethodExactIn, + simulatorAddress: "osmo13t8prr8hu7hkuksnfrd25vpvvnrfxr223k59ph", + slippageToleranceStr: "0.01", + want: osmomath.MustNewDecFromStr("0.01"), + }, + { + name: "exact out swap method", + swapMethod: domain.TokenSwapMethodExactOut, + simulatorAddress: "osmo13t8prr8hu7hkuksnfrd25vpvvnrfxr223k59ph", + slippageToleranceStr: "0.01", + want: osmomath.MustNewDecFromStr("0.01"), + + expectedError: true, + }, + { + name: "invalid simulator address", + swapMethod: domain.TokenSwapMethodExactIn, + simulatorAddress: "invalid", + slippageToleranceStr: "0.01", + expectedError: true, + }, + { + name: "empty slippage tolerance", + swapMethod: domain.TokenSwapMethodExactIn, + simulatorAddress: "osmo13t8prr8hu7hkuksnfrd25vpvvnrfxr223k59ph", + slippageToleranceStr: "", + expectedError: true, + }, + { + name: "invalid slippage tolerance", + swapMethod: domain.TokenSwapMethodExactIn, + simulatorAddress: "osmo13t8prr8hu7hkuksnfrd25vpvvnrfxr223k59ph", + slippageToleranceStr: "invalid", + expectedError: true, + }, + { + name: "exact out with no simulator address or slippage tolerance", + swapMethod: domain.TokenSwapMethodExactOut, + simulatorAddress: "", + slippageToleranceStr: "", + want: osmomath.Dec{}, + }, + { + name: "exact in with no simulator address or slippage tolerance", + swapMethod: domain.TokenSwapMethodExactIn, + simulatorAddress: "", + slippageToleranceStr: "", + want: osmomath.Dec{}, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + got, err := types.ValidateSimulationParams(tt.swapMethod, tt.simulatorAddress, tt.slippageToleranceStr) + if tt.expectedError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/router/usecase/quote_out_given_in.go b/router/usecase/quote_out_given_in.go index 0f1560afc..a3411c602 100644 --- a/router/usecase/quote_out_given_in.go +++ b/router/usecase/quote_out_given_in.go @@ -36,12 +36,13 @@ func NewQuoteExactAmountOut(q *QuoteExactAmountIn) *quoteExactAmountOut { // quoteExactAmountIn is a quote implementation for token swap method exact in. type quoteExactAmountIn struct { - AmountIn sdk.Coin "json:\"amount_in\"" - AmountOut osmomath.Int "json:\"amount_out\"" - Route []domain.SplitRoute "json:\"route\"" - EffectiveFee osmomath.Dec "json:\"effective_fee\"" - PriceImpact osmomath.Dec "json:\"price_impact\"" - InBaseOutQuoteSpotPrice osmomath.Dec "json:\"in_base_out_quote_spot_price\"" + AmountIn sdk.Coin "json:\"amount_in\"" + AmountOut osmomath.Int "json:\"amount_out\"" + Route []domain.SplitRoute "json:\"route\"" + 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 +152,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 +} diff --git a/router/usecase/routertesting/parsing/quote_amount_in_response_simulated.json b/router/usecase/routertesting/parsing/quote_amount_in_response_simulated.json new file mode 100644 index 000000000..43eae6696 --- /dev/null +++ b/router/usecase/routertesting/parsing/quote_amount_in_response_simulated.json @@ -0,0 +1,57 @@ +{ + "amount_in": { + "denom": "ibc/EA1D43981D5C9A1C4AAEA9C23BB1D4FA126BA9BC7020A25E0AE4AA841EA25DC5", + "amount": "10000000" + }, + "amount_out": "40000000", + "route": [ + { + "pools": [ + { + "id": 1, + "type": 0, + "balances": [], + "spread_factor": "0.010000000000000000", + "token_out_denom": "ibc/4ABBEF4C8926DDDB320AE5188CFD63267ABBCEFC0583E4AE05D6E5AA2401DDAB", + "taker_fee": "0.020000000000000000" + }, + { + "id": 2, + "type": 0, + "balances": [], + "spread_factor": "0.030000000000000000", + "token_out_denom": "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4", + "taker_fee": "0.000400000000000000" + } + ], + "has-cw-pool": false, + "out_amount": "20000000", + "in_amount": "5000000" + }, + { + "pools": [ + { + "id": 3, + "type": 0, + "balances": [], + "spread_factor": "0.005000000000000000", + "token_out_denom": "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4", + "taker_fee": "0.003000000000000000" + } + ], + "has-cw-pool": false, + "out_amount": "20000000", + "in_amount": "5000000" + } + ], + "effective_fee": "0.011696000000000000", + "price_impact": "-0.565353638051463862", + "in_base_out_quote_spot_price": "4.500000000000000000", + "price_info": { + "adjusted_gas_used": 1000000, + "fee_coin": { + "denom": "uosmo", + "amount": "1000" + } + } +} diff --git a/tests/quote.py b/tests/quote.py index 5a1333360..1b1e88232 100644 --- a/tests/quote.py +++ b/tests/quote.py @@ -117,7 +117,7 @@ def calculate_expected_base_out_quote_spot_price(denom_out, coin): return expected_in_base_out_quote_price, expected_token_in, token_in_amount_usdc_value - def run_quote_test(environment_url, token_in, token_out, human_denoms, single_route, expected_latency_upper_bound_ms, expected_status_code=200) -> QuoteExactAmountInResponse: + def run_quote_test(environment_url, token_in, token_out, human_denoms, single_route, expected_latency_upper_bound_ms, expected_status_code=200, simulator_address="", simulation_slippage_tolerance="") -> QuoteExactAmountInResponse: """ Runs a test for the /router/quote endpoint with the given input parameters. @@ -130,12 +130,18 @@ def run_quote_test(environment_url, token_in, token_out, human_denoms, single_ro - Latency is under the given bound """ - service_call = lambda: conftest.SERVICE_MAP[environment_url].get_exact_amount_in_quote(token_in, token_out, human_denoms, single_route) + service_call = lambda: conftest.SERVICE_MAP[environment_url].get_exact_amount_in_quote(token_in, token_out, human_denoms, single_route, simulator_address, simulation_slippage_tolerance) response = Quote.run_quote_test(service_call, expected_latency_upper_bound_ms, expected_status_code) # Return route for more detailed validation - return QuoteExactAmountInResponse(**response) + quote_response = QuoteExactAmountInResponse(**response) + + price_info = response.get("price_info") + if price_info: + quote_response.price_info = price_info + + return quote_response @staticmethod def validate_quote_test(quote, expected_amount_in_str, expected_denom_in, spot_price_scaling_factor, expected_in_base_out_quote_price, expected_token_out, denom_out, error_tolerance, direct_quote=False): diff --git a/tests/quote_response.py b/tests/quote_response.py index d086549f9..e348227da 100644 --- a/tests/quote_response.py +++ b/tests/quote_response.py @@ -33,13 +33,15 @@ def __init__(self, pools, out_amount, in_amount, **kwargs): # QuoteExactAmountInResponse represents the response format # of the /router/quote endpoint for Exact Amount In Quote. class QuoteExactAmountInResponse: - def __init__(self, amount_in, amount_out, route, effective_fee, price_impact, in_base_out_quote_spot_price): + def __init__(self, amount_in, amount_out, route, effective_fee, price_impact, in_base_out_quote_spot_price, price_info=None): self.amount_in = Coin(**amount_in) self.amount_out = int(amount_out) self.route = [Route(**r) for r in route] self.effective_fee = Decimal(effective_fee) self.price_impact = Decimal(price_impact) self.in_base_out_quote_spot_price = Decimal(in_base_out_quote_spot_price) + if price_info: + self.price_info = price_info def get_pool_ids(self): pool_ids = [] diff --git a/tests/sqs_service.py b/tests/sqs_service.py index 8657f3886..77e33fc09 100644 --- a/tests/sqs_service.py +++ b/tests/sqs_service.py @@ -95,7 +95,7 @@ def get_candidate_routes(self, denom_in, denom_out, human_denoms="false"): # Send the GET request return requests.get(self.url + ROUTER_ROUTES_URL, params=params, headers=self.headers) - def get_exact_amount_in_quote(self, denom_in, denom_out, human_denoms="false", singleRoute="false"): + def get_exact_amount_in_quote(self, denom_in, denom_out, human_denoms="false", singleRoute="false", simulator_address="", simulation_slippage_tolerance=""): """ Fetches exact amount in quote from the specified endpoint and returns it. @@ -108,6 +108,8 @@ def get_exact_amount_in_quote(self, denom_in, denom_out, human_denoms="false", s "tokenOutDenom": denom_out, "humanDenoms": human_denoms, "singleRoute": singleRoute, + "simulatorAddress": simulator_address, + "simulationSlippageTolerance": simulation_slippage_tolerance, } # Send the GET request diff --git a/tests/test_router_quote_out_given_in.py b/tests/test_router_quote_out_given_in.py index 1eb2baefa..93c96e8db 100644 --- a/tests/test_router_quote_out_given_in.py +++ b/tests/test_router_quote_out_given_in.py @@ -83,7 +83,6 @@ def test_usdc_in_high_liq_out(self, environment_url, coin_obj): # Set the token in coin token_in_coin = amount_str + USDC - # Run the quote test quote = ExactAmountInQuote.run_quote_test(environment_url, token_in_coin, denom_out, False, False, EXPECTED_LATENCY_UPPER_BOUND_MS) @@ -287,3 +286,26 @@ def test_orderbook(self, environment_url, amount, token_pair): amount_out_diff = relative_error(expected_amount_out, amount_out) assert amount_out_diff < error_tolerance, \ f"Error: difference between calculated and actual amount out is {amount_out_diff} which is greater than {error_tolerance}" + + + def test_simulation_slippage_tolerance(self, environment_url): + """ + This test validates that the simulation slippage tolerance is working as expected. + """ + token_in_coin = "1000000uosmo" + denom_out = "uion" + + expected_status_code = 200 + + # Fillbot address and slippage tolerance + # We choose fillbot address because we expect it to have at least one OSMO. + fillbot_address = "osmo10s3vlv40h64qs2p98yal9w0tpm4r30uyg6ceux" + # Note: relaxed + simulation_slippage_tolerance = 0.8 + + # Run the quote test + quote = ExactAmountInQuote.run_quote_test(environment_url, token_in_coin, denom_out, False, True, EXPECTED_LATENCY_UPPER_BOUND_MS, expected_status_code, fillbot_address, simulation_slippage_tolerance) + + # Validate that the price info is set to something + assert quote is not None + assert quote.price_info is not None