From e79f0807a1c99c16f4c81b3d4ed2bacf4ef2e1a2 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Thu, 31 Oct 2024 14:07:15 +0200 Subject: [PATCH 01/12] chore: add mergify backport on tag action (#542) adding backport mergify action and created A:backport/v27.x label --- .github/mergify.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/mergify.yml b/.github/mergify.yml index 5a7068a20..a09929e5e 100644 --- a/.github/mergify.yml +++ b/.github/mergify.yml @@ -62,3 +62,11 @@ pull_request_rules: backport: branches: - v25.x + - name: backport patches to v27.x branch + conditions: + - base=v26.x + - label=A:backport/v27.x + actions: + backport: + branches: + - v27.x From 1a4311b2501258d16de1808b820a2c357acb56ce Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Thu, 31 Oct 2024 22:55:09 +0200 Subject: [PATCH 02/12] chore: add required labels check for backporting (#543) --- .github/workflows/required-labels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/required-labels.yml b/.github/workflows/required-labels.yml index c550d6e6e..2c6a46a39 100644 --- a/.github/workflows/required-labels.yml +++ b/.github/workflows/required-labels.yml @@ -4,7 +4,7 @@ on: pull_request: types: [opened, labeled, unlabeled, synchronize] branches: - - "v25.x" + - "v26.x" jobs: backport_labels: @@ -26,4 +26,4 @@ jobs: with: #Require one of the following labels mode: exactly count: 1 - labels: "A:backport/v26.x" + labels: "A:backport/v27.x" From 2a46eeb4a5cd4dd418fb9d23e68875b53d233720 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Sun, 3 Nov 2024 06:31:03 +0200 Subject: [PATCH 03/12] BE-586 | Claimbot (#524) * BE-586 | Claimbot prototype Init * BE-586 | WIP * BE-586 | WIP * BE-595 | Clean up * BE-586 | Add docs * BE-586 | Clean up * BE-586 | Add docs * BE-596 | Tests init * BE-586 | Add tests for slices, orderbook packages * BE-586 | claimbot/tx tests * BE-586 | claimbot/order.go tests * BE-586 | Requested changes * BE-586 | Process block orderbooks, tests * BE-586 | Requested changes * BE-586 | Config update * BE-586 | OrderBookClient use slices.Split for pagination Cleans up OrderBookClient by reusing slices.Split instead of duplicating splitting slices into chunks logic in some of the methods. * BE-586 | Clean up * BE-586 | Fix fillbot docker-compose Fixes errors running fillbot via docker-compose * BE-586 | Docs, docker compose fixes * BE-586 | Run fillbot via docker-compose * BE-586 | Run claimbot via docker-compose, clean up * BE-586 | Cleanup * BE-586 | Named logger * BE-586 | Requested changes * BE-586 | Logging failing tx * BE-586 | Increase gas adjustment * BE-586 | Error logging fix * BE-586 | Trace name update * BE-586 | Requested changes #1 * BE-586 | Requested changes #2 * BE-586 | Sequence number update * BE-586 | added tests * BE-586 | Suggested improvements --- Makefile | 18 +- app/sidecar_query_server.go | 24 +- config.json | 32 +-- domain/config.go | 10 +- domain/cosmos/tx/tx.go | 2 +- domain/mocks/orderbook_usecase_mock.go | 26 ++- domain/mvc/orderbook.go | 6 + domain/orderbook/order.go | 16 ++ domain/orderbook/orderbook_tick.go | 16 ++ domain/orderbook/plugin/config.go | 5 +- .../orderbook/{fillbot => claimbot}/.env | 2 +- .../plugins/orderbook/claimbot/README.md | 69 ++++++ .../plugins/orderbook/claimbot/config.go | 54 +++++ .../orderbook/claimbot/docker-compose.yml | 147 ++++++++++++ .../plugins/orderbook/claimbot/export_test.go | 61 +++++ .../plugins/orderbook/claimbot/order.go | 52 +++++ .../plugins/orderbook/claimbot/order_test.go | 143 ++++++++++++ .../plugins/orderbook/claimbot/orderbook.go | 22 ++ .../orderbook/claimbot/orderbook_test.go | 77 +++++++ .../plugins/orderbook/claimbot/plugin.go | 213 ++++++++++++++++++ .../usecase/plugins/orderbook/claimbot/tx.go | 99 ++++++++ .../plugins/orderbook/claimbot/tx_test.go | 161 +++++++++++++ .../plugins/orderbook/fillbot/README.md | 2 +- .../orderbook/fillbot/create_copy_config.sh | 12 +- log/logger.go | 16 +- orderbook/usecase/orderbook_usecase.go | 93 ++++++++ orderbook/usecase/orderbook_usecase_test.go | 117 ++++++++++ orderbook/usecase/orderbooktesting/suite.go | 21 +- .../memory_router_repository_test.go | 2 +- 29 files changed, 1484 insertions(+), 34 deletions(-) rename ingest/usecase/plugins/orderbook/{fillbot => claimbot}/.env (74%) create mode 100644 ingest/usecase/plugins/orderbook/claimbot/README.md create mode 100644 ingest/usecase/plugins/orderbook/claimbot/config.go create mode 100644 ingest/usecase/plugins/orderbook/claimbot/docker-compose.yml create mode 100644 ingest/usecase/plugins/orderbook/claimbot/export_test.go create mode 100644 ingest/usecase/plugins/orderbook/claimbot/order.go create mode 100644 ingest/usecase/plugins/orderbook/claimbot/order_test.go create mode 100644 ingest/usecase/plugins/orderbook/claimbot/orderbook.go create mode 100644 ingest/usecase/plugins/orderbook/claimbot/orderbook_test.go create mode 100644 ingest/usecase/plugins/orderbook/claimbot/plugin.go create mode 100644 ingest/usecase/plugins/orderbook/claimbot/tx.go create mode 100644 ingest/usecase/plugins/orderbook/claimbot/tx_test.go diff --git a/Makefile b/Makefile index 4c9c0494c..5e9bf8196 100644 --- a/Makefile +++ b/Makefile @@ -243,11 +243,25 @@ orderbook-fillbot-start: ./ingest/usecase/plugins/orderbook/fillbot/create_copy_config.sh cd ./ingest/usecase/plugins/orderbook/fillbot && docker compose up -d cd ../../../../ - echo "Order Book Filler Bot Started" + echo "Orderbook Fill Bot Started" sleep 10 && osmosisd status sleep 10 && docker logs -f osmosis-sqs orderbook-fillbot-stop: cd ./ingest/usecase/plugins/orderbook/fillbot && docker compose down cd ../../../../ - echo "Order Book Filler Bot Stopped" + echo "Orderbook Fill Bot Stopped" + + +orderbook-claimbot-start: + ./ingest/usecase/plugins/orderbook/fillbot/create_copy_config.sh + cd ./ingest/usecase/plugins/orderbook/claimbot && docker compose up -d + cd ../../../../ + echo "Orderbook Claim Bot Started" + sleep 10 && osmosisd status + sleep 10 && docker logs -f osmosis-sqs + +orderbook-claimbot-stop: + cd ./ingest/usecase/plugins/orderbook/claimbot && docker compose down + cd ../../../../ + echo "Orderbook Claim Bot Stopped" diff --git a/app/sidecar_query_server.go b/app/sidecar_query_server.go index 2b55868dd..4011d3b3f 100644 --- a/app/sidecar_query_server.go +++ b/app/sidecar_query_server.go @@ -22,6 +22,7 @@ import ( 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" @@ -271,7 +272,7 @@ func NewSideCarQueryServer(appCodec codec.Codec, config domain.Config, logger lo if plugin.IsEnabled() { var currentPlugin domain.EndBlockProcessPlugin - if plugin.GetName() == orderbookplugindomain.OrderBookPluginName { + if plugin.GetName() == orderbookplugindomain.OrderbookFillbotPlugin { // Create keyring keyring, err := keyring.New() if err != nil { @@ -282,6 +283,27 @@ func NewSideCarQueryServer(appCodec codec.Codec, config domain.Config, logger lo currentPlugin = orderbookfillbot.New(poolsUseCase, routerUsecase, tokensUseCase, passthroughGRPCClient, orderBookAPIClient, keyring, defaultQuoteDenom, logger) } + if plugin.GetName() == orderbookplugindomain.OrderbookClaimbotPlugin { + // Create keyring + keyring, err := keyring.New() + if err != nil { + return nil, err + } + + logger.Info("Using keyring with address", zap.Stringer("address", keyring.GetAddress())) + currentPlugin, err = orderbookclaimbot.New( + keyring, + orderBookUseCase, + poolsUseCase, + logger, + config.ChainGRPCGatewayEndpoint, + config.ChainID, + ) + if err != nil { + return nil, err + } + } + // Register the plugin with the ingest use case ingestUseCase.RegisterEndBlockProcessPlugin(currentPlugin) } diff --git a/config.json b/config.json index 6b88f18b4..0489aaa74 100644 --- a/config.json +++ b/config.json @@ -1,15 +1,21 @@ { - "flight-record": { - "enabled": false - }, - "otel": { - "enabled": false, - "environment": "sqs-dev" - }, - "plugins": [ - { - "name": "orderbook", - "enabled": false - } - ] + "flight-record": { + "enabled": false + }, + "otel": { + "enabled": false, + "environment": "sqs-dev" + }, + "grpc-ingester": { + "plugins": [ + { + "name": "orderbook-fillbot-plugin", + "enabled": false + }, + { + "name": "orderbook-claimbot-plugin", + "enabled": false + } + ] + } } diff --git a/domain/config.go b/domain/config.go index a29c8718a..ce13a69e9 100644 --- a/domain/config.go +++ b/domain/config.go @@ -157,7 +157,11 @@ var ( Plugins: []Plugin{ &OrderBookPluginConfig{ Enabled: false, - Name: orderbookplugindomain.OrderBookPluginName, + Name: orderbookplugindomain.OrderbookFillbotPlugin, + }, + &OrderBookPluginConfig{ + Enabled: false, + Name: orderbookplugindomain.OrderbookClaimbotPlugin, }, }, }, @@ -377,7 +381,9 @@ func validateDynamicMinLiquidityCapDesc(values []DynamicMinLiquidityCapFilterEnt // PluginFactory creates a Plugin instance based on the provided name. func PluginFactory(name string) Plugin { switch name { - case orderbookplugindomain.OrderBookPluginName: + case orderbookplugindomain.OrderbookFillbotPlugin: + return &OrderBookPluginConfig{} + case orderbookplugindomain.OrderbookClaimbotPlugin: return &OrderBookPluginConfig{} // Add cases for other plugins as needed default: diff --git a/domain/cosmos/tx/tx.go b/domain/cosmos/tx/tx.go index 2c6673523..5c7353e0e 100644 --- a/domain/cosmos/tx/tx.go +++ b/domain/cosmos/tx/tx.go @@ -119,7 +119,7 @@ func SimulateMsgs( txFactory = txFactory.WithAccountNumber(account.AccountNumber) txFactory = txFactory.WithSequence(account.Sequence) txFactory = txFactory.WithChainID(chainID) - txFactory = txFactory.WithGasAdjustment(1.05) + txFactory = txFactory.WithGasAdjustment(1.15) // Estimate transaction gasResult, adjustedGasUsed, err := gasCalculator.CalculateGas( diff --git a/domain/mocks/orderbook_usecase_mock.go b/domain/mocks/orderbook_usecase_mock.go index 0f9e2283a..2652fe4bd 100644 --- a/domain/mocks/orderbook_usecase_mock.go +++ b/domain/mocks/orderbook_usecase_mock.go @@ -7,17 +7,20 @@ import ( "github.com/osmosis-labs/sqs/domain/mvc" orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" "github.com/osmosis-labs/sqs/sqsdomain" + + "github.com/osmosis-labs/osmosis/osmomath" ) var _ mvc.OrderBookUsecase = &OrderbookUsecaseMock{} // OrderbookUsecaseMock is a mock implementation of the RouterUsecase interface type OrderbookUsecaseMock struct { - ProcessPoolFunc func(ctx context.Context, pool sqsdomain.PoolI) error - GetAllTicksFunc func(poolID uint64) (map[int64]orderbookdomain.OrderbookTick, bool) - GetActiveOrdersFunc func(ctx context.Context, address string) ([]orderbookdomain.LimitOrder, bool, error) - GetActiveOrdersStreamFunc func(ctx context.Context, address string) <-chan orderbookdomain.OrderbookResult - CreateFormattedLimitOrderFunc func(orderbook domain.CanonicalOrderBooksResult, order orderbookdomain.Order) (orderbookdomain.LimitOrder, error) + ProcessPoolFunc func(ctx context.Context, pool sqsdomain.PoolI) error + GetAllTicksFunc func(poolID uint64) (map[int64]orderbookdomain.OrderbookTick, bool) + GetActiveOrdersFunc func(ctx context.Context, address string) ([]orderbookdomain.LimitOrder, bool, error) + GetActiveOrdersStreamFunc func(ctx context.Context, address string) <-chan orderbookdomain.OrderbookResult + CreateFormattedLimitOrderFunc func(orderbook domain.CanonicalOrderBooksResult, order orderbookdomain.Order) (orderbookdomain.LimitOrder, error) + GetClaimableOrdersForOrderbookFunc func(ctx context.Context, fillThreshold osmomath.Dec, orderbook domain.CanonicalOrderBooksResult) ([]orderbookdomain.ClaimableOrderbook, error) } func (m *OrderbookUsecaseMock) ProcessPool(ctx context.Context, pool sqsdomain.PoolI) error { @@ -59,3 +62,16 @@ func (m *OrderbookUsecaseMock) CreateFormattedLimitOrder(orderbook domain.Canoni } panic("unimplemented") } + +func (m *OrderbookUsecaseMock) GetClaimableOrdersForOrderbook(ctx context.Context, fillThreshold osmomath.Dec, orderbook domain.CanonicalOrderBooksResult) ([]orderbookdomain.ClaimableOrderbook, error) { + if m.GetClaimableOrdersForOrderbookFunc != nil { + return m.GetClaimableOrdersForOrderbookFunc(ctx, fillThreshold, orderbook) + } + panic("unimplemented") +} + +func (m *OrderbookUsecaseMock) WithGetClaimableOrdersForOrderbook(orders []orderbookdomain.ClaimableOrderbook, err error) { + m.GetClaimableOrdersForOrderbookFunc = func(ctx context.Context, fillThreshold osmomath.Dec, orderbook domain.CanonicalOrderBooksResult) ([]orderbookdomain.ClaimableOrderbook, error) { + return orders, err + } +} diff --git a/domain/mvc/orderbook.go b/domain/mvc/orderbook.go index b565dded9..c1cb18206 100644 --- a/domain/mvc/orderbook.go +++ b/domain/mvc/orderbook.go @@ -3,6 +3,7 @@ package mvc import ( "context" + "github.com/osmosis-labs/osmosis/osmomath" "github.com/osmosis-labs/sqs/domain" orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" "github.com/osmosis-labs/sqs/sqsdomain" @@ -25,4 +26,9 @@ type OrderBookUsecase interface { // CreateFormattedLimitOrder creates a formatted limit order from the given orderbook and order. CreateFormattedLimitOrder(orderbook domain.CanonicalOrderBooksResult, order orderbookdomain.Order) (orderbookdomain.LimitOrder, error) + + // GetClaimableOrdersForOrderbook retrieves all claimable orders for a given orderbook. + // It fetches all ticks for the orderbook, processes each tick to find claimable orders, + // and returns a combined list of all claimable orders across all ticks. + GetClaimableOrdersForOrderbook(ctx context.Context, fillThreshold osmomath.Dec, orderbook domain.CanonicalOrderBooksResult) ([]orderbookdomain.ClaimableOrderbook, error) } diff --git a/domain/orderbook/order.go b/domain/orderbook/order.go index ceff8f763..66d542522 100644 --- a/domain/orderbook/order.go +++ b/domain/orderbook/order.go @@ -119,3 +119,19 @@ type OrderbookResult struct { IsBestEffort bool Error error } + +// ClaimableOrderbook represents a list of claimable orders for an orderbook. +// If an error occurs processing the orders, it is stored in the error field. +type ClaimableOrderbook struct { + Tick OrderbookTick + Orders []ClaimableOrder + Error error +} + +// ClaimableOrder represents an order that is claimable. +// If an error occurs processing the order, it is stored in the error field +// and the order is nil. +type ClaimableOrder struct { + Order Order + Error error +} diff --git a/domain/orderbook/orderbook_tick.go b/domain/orderbook/orderbook_tick.go index 0a6bb9e60..c7426b3d6 100644 --- a/domain/orderbook/orderbook_tick.go +++ b/domain/orderbook/orderbook_tick.go @@ -53,3 +53,19 @@ type TickValues struct { // sync. LastTickSyncEtas string `json:"last_tick_sync_etas"` } + +// isTickFullyFilled checks if a tick is fully filled by comparing its cumulative total value +// to its effective total amount swapped. +func (tv *TickValues) IsTickFullyFilled() (bool, error) { + cumulativeTotalValue, err := osmomath.NewDecFromStr(tv.CumulativeTotalValue) + if err != nil { + return false, err + } + + effectiveTotalAmountSwapped, err := osmomath.NewDecFromStr(tv.EffectiveTotalAmountSwapped) + if err != nil { + return false, err + } + + return cumulativeTotalValue.Equal(effectiveTotalAmountSwapped), nil +} diff --git a/domain/orderbook/plugin/config.go b/domain/orderbook/plugin/config.go index 37139f10d..4b8004a95 100644 --- a/domain/orderbook/plugin/config.go +++ b/domain/orderbook/plugin/config.go @@ -1,6 +1,7 @@ package orderbookplugindomain +// Orderbook plugin names const ( - // OrderBookPluginName is the name of the orderbook plugin. - OrderBookPluginName = "orderbook" + OrderbookFillbotPlugin = "orderbook-fillbot-plugin" + OrderbookClaimbotPlugin = "orderbook-claimbot-plugin" ) diff --git a/ingest/usecase/plugins/orderbook/fillbot/.env b/ingest/usecase/plugins/orderbook/claimbot/.env similarity index 74% rename from ingest/usecase/plugins/orderbook/fillbot/.env rename to ingest/usecase/plugins/orderbook/claimbot/.env index 643413946..ceb560988 100644 --- a/ingest/usecase/plugins/orderbook/fillbot/.env +++ b/ingest/usecase/plugins/orderbook/claimbot/.env @@ -1,4 +1,4 @@ DD_API_KEY=YOUR_API_KEY OSMOSIS_KEYRING_PATH=/root/.osmosisd/keyring-test OSMOSIS_KEYRING_PASSWORD=test -OSMOSIS_KEYRING_KEY_NAME=local.info +OSMOSIS_KEYRING_KEY_NAME=local.info \ No newline at end of file diff --git a/ingest/usecase/plugins/orderbook/claimbot/README.md b/ingest/usecase/plugins/orderbook/claimbot/README.md new file mode 100644 index 000000000..240828765 --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/README.md @@ -0,0 +1,69 @@ +# Orderbook Claimbot Plugin + +The Orderbook Claimbot plugin is a plugin that claims filled order book orders. + +It scans all active orders for each order book determining which orders have been filled and need to be claimed. At the moment order is said to be claimable if it is filled 98 percent or more. In order for an order book to be processed to claim its active orders it must be canonical as per SQS definition. + + +Such order book scanning and claiming is achieved by listening for new blocks and core logic is triggered at the end of each new block by calling Claimbot `ProcessEndBlock` method. + +## Configuration + +### Node + +1. Initialize a fresh node with the `osmosisd` binary. +```bash +osmosisd init claim-bot --chain-id osmosis-1 +``` + +2. Get latest snapshot from [here](https://snapshots.osmosis.zone/index.html) + +3. Go to `$HOME/.osmosisd/config/app.toml` and set `osmosis-sqs.is-enabled` to true + +4. Optionally, turn off any services from `app.toml` and `config.toml` that you don't need + +### SQS + +In `config.json`, set the plugin to enabled: + +```json +"grpc-ingester":{ + ... + "plugins": [ + { + "name": "orderbook-claimbot-plugin", + "enabled": true + } + ] +}, +``` + +Configure the key on a test keyring, and set the following environment variables: +```bash +OSMOSIS_KEYRING_PATH=/root/.osmosisd/keyring-test +OSMOSIS_KEYRING_PASSWORD=test +OSMOSIS_KEYRING_KEY_NAME=local.info +``` +- Here, the key is named `local` and the keyring path is in the default `osmosisd` home directory. + +To create your key: +```bash +osmosisd keys add local --keyring-backend test --recover + +# Enter your mnemonic + +# Confirm the key is created +osmosisd keys list --keyring-backend test +``` + +Note that the test keyring is not a secure approach but we opted-in for simplicity and speed +of PoC implementation. In the future, this can be improved to support multiple backends. + +## Starting (via docker compose) + +1. Ensure that the "Configuration" section is complete. +2. From project root, `cd` into `ingest/usecase/plugins/orderbook/claimbot` +3. Update `.env` with your environment variables. +4. Run `make orderbook-claimbot-start` +5. Run `osmosisd status` to check that the node is running and caught up to tip. +6. Curl `/healthcheck` to check that SQS is running `curl http://localhost:9092/healthcheck` diff --git a/ingest/usecase/plugins/orderbook/claimbot/config.go b/ingest/usecase/plugins/orderbook/claimbot/config.go new file mode 100644 index 000000000..cc25934bd --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/config.go @@ -0,0 +1,54 @@ +package claimbot + +import ( + "github.com/osmosis-labs/sqs/delivery/grpc" + authtypes "github.com/osmosis-labs/sqs/domain/cosmos/auth/types" + sqstx "github.com/osmosis-labs/sqs/domain/cosmos/tx" + "github.com/osmosis-labs/sqs/domain/keyring" + "github.com/osmosis-labs/sqs/domain/mvc" + "github.com/osmosis-labs/sqs/log" + + txfeestypes "github.com/osmosis-labs/osmosis/v26/x/txfees/types" + + txtypes "github.com/cosmos/cosmos-sdk/types/tx" +) + +// Config is the configuration for the claimbot plugin +type Config struct { + Keyring keyring.Keyring + PoolsUseCase mvc.PoolsUsecase + OrderbookUsecase mvc.OrderBookUsecase + AccountQueryClient authtypes.QueryClient + TxfeesClient txfeestypes.QueryClient + GasCalculator sqstx.GasCalculator + TxServiceClient txtypes.ServiceClient + ChainID string + Logger log.Logger +} + +// NewConfig creates a new Config instance. +func NewConfig( + keyring keyring.Keyring, + orderbookusecase mvc.OrderBookUsecase, + poolsUseCase mvc.PoolsUsecase, + logger log.Logger, + chainGRPCGatewayEndpoint string, + chainID string, +) (*Config, error) { + grpcClient, err := grpc.NewClient(chainGRPCGatewayEndpoint) + if err != nil { + return nil, err + } + + return &Config{ + Keyring: keyring, + PoolsUseCase: poolsUseCase, + OrderbookUsecase: orderbookusecase, + AccountQueryClient: authtypes.NewQueryClient(grpcClient), + TxfeesClient: txfeestypes.NewQueryClient(grpcClient), + GasCalculator: sqstx.NewGasCalculator(grpcClient), + TxServiceClient: txtypes.NewServiceClient(grpcClient), + Logger: logger.Named("claimbot"), + ChainID: chainID, + }, nil +} diff --git a/ingest/usecase/plugins/orderbook/claimbot/docker-compose.yml b/ingest/usecase/plugins/orderbook/claimbot/docker-compose.yml new file mode 100644 index 000000000..2bea623d0 --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/docker-compose.yml @@ -0,0 +1,147 @@ +version: "3" + +services: + osmosis: + labels: + com.datadoghq.ad.logs: >- + [{ + "source": "osmosis", + "service": "osmosis", + "log_processing_rules": [{ + "type": "exclude_at_match", + "name": "exclude_p2p_module", + "pattern": "\"module\":\\s*\".*p2p.*\"" + }] + }] + environment: + - DD_AGENT_HOST=dd-agent + - OTEL_EXPORTER_OTLP_ENDPOINT=http://dd-agent:4317 + - DD_SERVICE=osmosis + - DD_ENV=prod + - DD_VERSION=25.0.0 + command: + - start + - --home=/osmosis/.osmosisd + image: osmolabs/osmosis:26.0.2 + container_name: osmosis + restart: always + ports: + - 26657:26657 + - 1317:1317 + - 9191:9090 + - 9091:9091 + - 26660:26660 + - 6060:6060 + volumes: + - ${HOME}/.osmosisd/:/osmosis/.osmosisd/ + logging: + driver: "json-file" + options: + max-size: "2048m" + max-file: "3" + tag: "{{.ImageName}}|{{.Name}}" + + osmosis-sqs: + environment: + - DD_AGENT_HOST=dd-agent + - OTEL_EXPORTER_OTLP_ENDPOINT=http://dd-agent:4317 + - DD_SERVICE=sqs + - DD_ENV=prod + - DD_VERSION=25.0.0 + - OSMOSIS_KEYRING_PATH=${OSMOSIS_KEYRING_PATH} + - OSMOSIS_KEYRING_PASSWORD=${OSMOSIS_KEYRING_PASSWORD} + - OSMOSIS_KEYRING_KEY_NAME=${OSMOSIS_KEYRING_KEY_NAME} + - SQS_GRPC_TENDERMINT_RPC_ENDPOINT=http://osmosis:26657 + - SQS_GRPC_GATEWAY_ENDPOINT=osmosis:9090 + - SQS_OTEL_ENVIRONMENT=sqs-claim-bot + command: + - --host + - sqs-claim-bot + - --config + - /etc/config.json + build: + context: ../../../../../ + dockerfile: Dockerfile + depends_on: + - osmosis + container_name: osmosis-sqs + restart: always + ports: + - 9092:9092 + volumes: + - ${OSMOSIS_KEYRING_PATH}:${OSMOSIS_KEYRING_PATH} + - ../../../../../config.json:/etc/config.json + logging: + driver: "json-file" + options: + max-size: "2048m" + max-file: "3" + tag: "{{.ImageName}}|{{.Name}}" + + dd-agent: + image: gcr.io/datadoghq/agent:7 + container_name: dd-agent + labels: + com.datadoghq.ad.checks: | + { + "openmetrics": { + "init_configs": [{}], + "instances": [ + { + "openmetrics_endpoint": "http://droid:8080/metrics", + "namespace": "osmosisd", + "metrics": + [ + {"osmosisd_info": "info"}, + {"osmosisd_cur_eip_base_fee": "cur_eip_base_fee"} + ] + }#, + # { + # "openmetrics_endpoint": "http://nginx/metrics", + # "namespace": "sqs", + # "metrics": [".*"] + # } + ] + } + } + environment: + - DD_API_KEY=${DD_API_KEY} + - DD_SITE=us5.datadoghq.com + - DD_ENV=prod + - DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT=0.0.0.0:4317 + - DD_APM_ENABLED=true + - DD_LOGS_ENABLED=true + - DD_LOGS_CONFIG_DOCKER_CONTAINER_FORCE_USE_FILE=true + - DD_LOGS_CONFIG_CONTAINER_COLLECT_ALL=true + - DD_CONTAINER_EXCLUDE_LOGS=image:.*agent.* image:.*droid.* + - DD_OTLP_CONFIG_LOGS_ENABLED=true + - DD_APM_PROBABILISTIC_SAMPLER_ENABLED=true + - DD_APM_PROBABILISTIC_SAMPLER_SAMPLING_PERCENTAGE=1 + + volumes: + - /var/run/docker.sock:/var/run/docker.sock:rw + - /proc/:/host/proc/:rw + - /sys/fs/cgroup/:/host/sys/fs/cgroup:rw + - /var/lib/docker/containers:/var/lib/docker/containers:rw + - /opt/datadog/apm:/opt/datadog/apm + ports: + - 4317:4317 + - 4318:4318 + - 8126:8126 + + droid: + image: osmolabs/droid:0.0.3 + container_name: droid + restart: unless-stopped + depends_on: + - osmosis + ports: + - "8080:8080" + environment: + RPC_ENDPOINT: "http://osmosis:26657" + LCD_ENDPOINT: "http://osmosis:1317" + EIP1559_ENABLED: "true" + logging: + driver: "json-file" + options: + max-size: "512m" diff --git a/ingest/usecase/plugins/orderbook/claimbot/export_test.go b/ingest/usecase/plugins/orderbook/claimbot/export_test.go new file mode 100644 index 000000000..ad9ee4597 --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/export_test.go @@ -0,0 +1,61 @@ +package claimbot + +import ( + "context" + + "github.com/osmosis-labs/sqs/domain" + sqstx "github.com/osmosis-labs/sqs/domain/cosmos/tx" + "github.com/osmosis-labs/sqs/domain/keyring" + "github.com/osmosis-labs/sqs/domain/mvc" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" + + txfeestypes "github.com/osmosis-labs/osmosis/v26/x/txfees/types" + + "github.com/osmosis-labs/osmosis/osmomath" + + sdk "github.com/cosmos/cosmos-sdk/types" + txtypes "github.com/cosmos/cosmos-sdk/types/tx" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" +) + +// ProcessedOrderbook is order alias data structure for testing purposes. +type ProcessedOrderbook = processedOrderbook + +// ProcessOrderbooksAndGetClaimableOrders is test wrapper for processOrderbooksAndGetClaimableOrders. +// This function is exported for testing purposes. +func ProcessOrderbooksAndGetClaimableOrders( + ctx context.Context, + orderbookusecase mvc.OrderBookUsecase, + fillThreshold osmomath.Dec, + orderbooks []domain.CanonicalOrderBooksResult, +) ([]ProcessedOrderbook, error) { + return processOrderbooksAndGetClaimableOrders(ctx, orderbookusecase, fillThreshold, orderbooks) +} + +// SendBatchClaimTx a test wrapper for sendBatchClaimTx. +// This function is used only for testing purposes. +func SendBatchClaimTx( + ctx context.Context, + keyring keyring.Keyring, + txfeesClient txfeestypes.QueryClient, + gasCalculator sqstx.GasCalculator, + txServiceClient txtypes.ServiceClient, + chainID string, + account *authtypes.BaseAccount, + contractAddress string, + claims orderbookdomain.Orders, +) (*sdk.TxResponse, error) { + return sendBatchClaimTx(ctx, keyring, txfeesClient, gasCalculator, txServiceClient, chainID, account, contractAddress, claims) +} + +// PrepareBatchClaimMsg is a test wrapper for prepareBatchClaimMsg. +// This function is exported for testing purposes. +func PrepareBatchClaimMsg(claims orderbookdomain.Orders) ([]byte, error) { + return prepareBatchClaimMsg(claims) +} + +// GetOrderbooks is a test wrapper for getOrderbooks. +// This function is exported for testing purposes. +func GetOrderbooks(poolsUsecase mvc.PoolsUsecase, metadata domain.BlockPoolMetadata) ([]domain.CanonicalOrderBooksResult, error) { + return getOrderbooks(poolsUsecase, metadata) +} diff --git a/ingest/usecase/plugins/orderbook/claimbot/order.go b/ingest/usecase/plugins/orderbook/claimbot/order.go new file mode 100644 index 000000000..f4c9d9c0e --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/order.go @@ -0,0 +1,52 @@ +package claimbot + +import ( + "context" + + "github.com/osmosis-labs/osmosis/osmomath" + "github.com/osmosis-labs/sqs/domain" + "github.com/osmosis-labs/sqs/domain/mvc" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" +) + +// processedOrderbook is a data structure +// containing the processed orderbook and its claimable orders. +type processedOrderbook struct { + Orderbook domain.CanonicalOrderBooksResult + Orders []orderbookdomain.ClaimableOrderbook + Error error +} + +// processOrderbooksAndGetClaimableOrders processes a list of orderbooks and returns claimable orders for each. +// Under the hood processing of each orderbook in done concurrently to speed up the process. +func processOrderbooksAndGetClaimableOrders( + ctx context.Context, + orderbookusecase mvc.OrderBookUsecase, + fillThreshold osmomath.Dec, + orderbooks []domain.CanonicalOrderBooksResult, +) ([]processedOrderbook, error) { + ch := make(chan processedOrderbook, len(orderbooks)) + + for _, orderbook := range orderbooks { + go func(orderbook domain.CanonicalOrderBooksResult) { + orders, err := orderbookusecase.GetClaimableOrdersForOrderbook(ctx, fillThreshold, orderbook) + ch <- processedOrderbook{ + Orderbook: orderbook, + Orders: orders, + Error: err, + } + }(orderbook) + } + + var results []processedOrderbook + for i := 0; i < len(orderbooks); i++ { + select { + case result := <-ch: + results = append(results, result) + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + return results, nil +} diff --git a/ingest/usecase/plugins/orderbook/claimbot/order_test.go b/ingest/usecase/plugins/orderbook/claimbot/order_test.go new file mode 100644 index 000000000..80c8786da --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/order_test.go @@ -0,0 +1,143 @@ +package claimbot_test + +import ( + "context" + "testing" + + "github.com/osmosis-labs/osmosis/osmomath" + "github.com/osmosis-labs/sqs/domain" + "github.com/osmosis-labs/sqs/domain/mocks" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" + "github.com/osmosis-labs/sqs/ingest/usecase/plugins/orderbook/claimbot" + + "github.com/stretchr/testify/assert" +) + +func TestProcessOrderbooksAndGetClaimableOrders(t *testing.T) { + newOrder := func(direction string) orderbookdomain.Order { + return orderbookdomain.Order{ + TickId: 1, + OrderId: 1, + OrderDirection: direction, + } + } + + newCanonicalOrderBooksResult := func(poolID uint64, contractAddress string) domain.CanonicalOrderBooksResult { + return domain.CanonicalOrderBooksResult{PoolID: poolID, ContractAddress: contractAddress} + } + + tests := []struct { + name string + fillThreshold osmomath.Dec + orderbooks []domain.CanonicalOrderBooksResult + mockSetup func(*mocks.OrderbookUsecaseMock) + expectedOrders []claimbot.ProcessedOrderbook + }{ + { + name: "No orderbooks", + fillThreshold: osmomath.NewDec(1), + orderbooks: []domain.CanonicalOrderBooksResult{}, + mockSetup: func(usecase *mocks.OrderbookUsecaseMock) { + }, + expectedOrders: nil, + }, + { + name: "Single orderbook with no claimable orders", + fillThreshold: osmomath.NewDecWithPrec(95, 2), // 0.95 + orderbooks: []domain.CanonicalOrderBooksResult{ + newCanonicalOrderBooksResult(10, "contract1"), + }, + mockSetup: func(usecase *mocks.OrderbookUsecaseMock) { + usecase.WithGetClaimableOrdersForOrderbook(nil, nil) + }, + expectedOrders: []claimbot.ProcessedOrderbook{ + { + Orderbook: newCanonicalOrderBooksResult(10, "contract1"), // orderbook with + Orders: nil, // no claimable orders + }, + }, + }, + { + name: "Tick fully filled: all orders are claimable", + fillThreshold: osmomath.NewDecWithPrec(99, 2), // 0.99 + orderbooks: []domain.CanonicalOrderBooksResult{ + newCanonicalOrderBooksResult(38, "contract8"), + }, + mockSetup: func(usecase *mocks.OrderbookUsecaseMock) { + usecase.WithGetClaimableOrdersForOrderbook( + []orderbookdomain.ClaimableOrderbook{ + { + Orders: []orderbookdomain.ClaimableOrder{ + { + Order: newOrder("bid"), + }, + }, + }, + }, nil) + }, + expectedOrders: []claimbot.ProcessedOrderbook{ + { + Orderbook: newCanonicalOrderBooksResult(38, "contract8"), + Orders: []orderbookdomain.ClaimableOrderbook{ + { + Orders: []orderbookdomain.ClaimableOrder{ + { + Order: newOrder("bid"), + }, + }, + }, + }, + }, + }, + }, + { + name: "Orderbook with claimable orders", + fillThreshold: osmomath.NewDecWithPrec(95, 2), // 0.95 + orderbooks: []domain.CanonicalOrderBooksResult{ + newCanonicalOrderBooksResult(64, "contract58"), + }, + mockSetup: func(usecase *mocks.OrderbookUsecaseMock) { + usecase.WithGetClaimableOrdersForOrderbook( + []orderbookdomain.ClaimableOrderbook{ + { + Orders: []orderbookdomain.ClaimableOrder{ + { + Order: newOrder("ask"), + }, + { + Order: newOrder("bid"), + }, + }, + }, + }, nil) + }, + expectedOrders: []claimbot.ProcessedOrderbook{ + { + Orderbook: newCanonicalOrderBooksResult(64, "contract58"), + Orders: []orderbookdomain.ClaimableOrderbook{ + { + Orders: []orderbookdomain.ClaimableOrder{ + {Order: newOrder("ask")}, + {Order: newOrder("bid")}, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + usecase := mocks.OrderbookUsecaseMock{} + + tt.mockSetup(&usecase) + + result, err := claimbot.ProcessOrderbooksAndGetClaimableOrders(ctx, &usecase, tt.fillThreshold, tt.orderbooks) + assert.NoError(t, err) + + assert.Equal(t, tt.expectedOrders, result) + }) + } +} diff --git a/ingest/usecase/plugins/orderbook/claimbot/orderbook.go b/ingest/usecase/plugins/orderbook/claimbot/orderbook.go new file mode 100644 index 000000000..9711874ba --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/orderbook.go @@ -0,0 +1,22 @@ +package claimbot + +import ( + "github.com/osmosis-labs/sqs/domain" + "github.com/osmosis-labs/sqs/domain/mvc" +) + +// getOrderbooks returns canonical orderbooks that are within the metadata. +func getOrderbooks(poolsUsecase mvc.PoolsUsecase, metadata domain.BlockPoolMetadata) ([]domain.CanonicalOrderBooksResult, error) { + orderbooks, err := poolsUsecase.GetAllCanonicalOrderbookPoolIDs() + if err != nil { + return nil, err + } + + var result []domain.CanonicalOrderBooksResult + for _, orderbook := range orderbooks { + if _, ok := metadata.PoolIDs[orderbook.PoolID]; ok { + result = append(result, orderbook) + } + } + return result, nil +} diff --git a/ingest/usecase/plugins/orderbook/claimbot/orderbook_test.go b/ingest/usecase/plugins/orderbook/claimbot/orderbook_test.go new file mode 100644 index 000000000..e8f48ddf6 --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/orderbook_test.go @@ -0,0 +1,77 @@ +package claimbot_test + +import ( + "testing" + + "github.com/osmosis-labs/sqs/domain" + "github.com/osmosis-labs/sqs/domain/mocks" + "github.com/osmosis-labs/sqs/ingest/usecase/plugins/orderbook/claimbot" + + "github.com/stretchr/testify/assert" +) + +func TestGetOrderbooks(t *testing.T) { + tests := []struct { + name string + metadata domain.BlockPoolMetadata + setupMocks func(*mocks.PoolsUsecaseMock) + want []domain.CanonicalOrderBooksResult + err bool + }{ + { + name: "Metadata contains all canonical orderbooks but one", + metadata: domain.BlockPoolMetadata{ + PoolIDs: map[uint64]struct{}{1: {}, 2: {}, 3: {}}, + }, + setupMocks: func(poolsUsecase *mocks.PoolsUsecaseMock) { + poolsUsecase.WithGetAllCanonicalOrderbookPoolIDs([]domain.CanonicalOrderBooksResult{ + {PoolID: 1}, {PoolID: 2}, {PoolID: 3}, {PoolID: 4}, + }, nil) + }, + want: []domain.CanonicalOrderBooksResult{ + {PoolID: 1}, {PoolID: 2}, {PoolID: 3}, + }, + err: false, + }, + { + name: "Metadata contains only canonical orderbooks", + metadata: domain.BlockPoolMetadata{ + PoolIDs: map[uint64]struct{}{1: {}, 2: {}, 3: {}}, + }, + setupMocks: func(poolsUsecase *mocks.PoolsUsecaseMock) { + poolsUsecase.WithGetAllCanonicalOrderbookPoolIDs([]domain.CanonicalOrderBooksResult{ + {PoolID: 1}, {PoolID: 2}, {PoolID: 3}, + }, nil) + }, + want: []domain.CanonicalOrderBooksResult{ + {PoolID: 1}, {PoolID: 2}, {PoolID: 3}, + }, + err: false, + }, + { + name: "Error getting all canonical orderbook pool IDs", + metadata: domain.BlockPoolMetadata{}, + setupMocks: func(poolsUsecase *mocks.PoolsUsecaseMock) { + poolsUsecase.WithGetAllCanonicalOrderbookPoolIDs(nil, assert.AnError) + }, + want: nil, + err: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + poolsUsecase := mocks.PoolsUsecaseMock{} + + tt.setupMocks(&poolsUsecase) + + got, err := claimbot.GetOrderbooks(&poolsUsecase, tt.metadata) + if tt.err { + assert.Error(t, err) + return + } + + assert.Equal(t, got, tt.want) + }) + } +} diff --git a/ingest/usecase/plugins/orderbook/claimbot/plugin.go b/ingest/usecase/plugins/orderbook/claimbot/plugin.go new file mode 100644 index 000000000..c9042c220 --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/plugin.go @@ -0,0 +1,213 @@ +package claimbot + +import ( + "context" + "fmt" + "sync/atomic" + + "github.com/osmosis-labs/sqs/domain" + "github.com/osmosis-labs/sqs/domain/keyring" + "github.com/osmosis-labs/sqs/domain/mvc" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" + "github.com/osmosis-labs/sqs/domain/slices" + "github.com/osmosis-labs/sqs/log" + + "github.com/osmosis-labs/osmosis/osmomath" + + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + + "go.opentelemetry.io/otel" + "go.uber.org/zap" +) + +// claimbot is a claim bot that processes and claims eligible orderbook orders at the end of each block. +// Claimable orders are determined based on order filled percentage that is handled with fillThreshold package level variable. +type claimbot struct { + config *Config + atomicBool atomic.Bool +} + +var _ domain.EndBlockProcessPlugin = &claimbot{} + +const ( + tracerName = "sqs-orderbook-claimbot" +) + +var ( + tracer = otel.Tracer(tracerName) + fillThreshold = osmomath.MustNewDecFromStr("0.98") +) + +// maxBatchOfClaimableOrders is the maximum number of claimable orders +// that can be processed in a single batch. +const maxBatchOfClaimableOrders = 100 + +// New creates and returns a new claimbot instance. +func New( + keyring keyring.Keyring, + orderbookusecase mvc.OrderBookUsecase, + poolsUsecase mvc.PoolsUsecase, + logger log.Logger, + chainGRPCGatewayEndpoint string, + chainID string, +) (*claimbot, error) { + config, err := NewConfig(keyring, orderbookusecase, poolsUsecase, logger, chainGRPCGatewayEndpoint, chainID) + if err != nil { + return nil, fmt.Errorf("failed to create config: %w", err) + } + + return &claimbot{ + config: config, + atomicBool: atomic.Bool{}, + }, nil +} + +// ProcessEndBlock implements domain.EndBlockProcessPlugin. +// This method is called at the end of each block to process and claim eligible orderbook orders. +// ProcessEndBlock implements domain.EndBlockProcessPlugin. +func (o *claimbot) ProcessEndBlock(ctx context.Context, blockHeight uint64, metadata domain.BlockPoolMetadata) error { + ctx, span := tracer.Start(ctx, "orderbookClaimbotIngestPlugin.ProcessEndBlock") + defer span.End() + + // For simplicity, we allow only one block to be processed at a time. + // This may be relaxed in the future. + if !o.atomicBool.CompareAndSwap(false, true) { + o.config.Logger.Info("already in progress", zap.Uint64("block_height", blockHeight)) + return nil + } + defer o.atomicBool.Store(false) + defer o.config.Logger.Info("processed end block", zap.Uint64("block_height", blockHeight)) + + orderbooks, err := getOrderbooks(o.config.PoolsUseCase, metadata) + if err != nil { + o.config.Logger.Warn( + "failed to get canonical orderbook pools for block", + zap.Uint64("block_height", blockHeight), + zap.Error(err), + ) + return err + } + + account, err := o.config.AccountQueryClient.GetAccount(ctx, o.config.Keyring.GetAddress().String()) + if err != nil { + return err + } + + // retrieve claimable proccessedOrderbooks for the orderbooks + proccessedOrderbooks, err := processOrderbooksAndGetClaimableOrders( + ctx, + o.config.OrderbookUsecase, + fillThreshold, + orderbooks, + ) + + if err != nil { + o.config.Logger.Warn( + "failed to process block orderbooks", + zap.Error(err), + ) + return err + } + + for _, orderbook := range proccessedOrderbooks { + if orderbook.Error != nil { + o.config.Logger.Warn( + "failed to retrieve claimable orders", + zap.String("contract_address", orderbook.Orderbook.ContractAddress), + zap.Error(orderbook.Error), + ) + continue + } + + var claimable orderbookdomain.Orders + for _, orderbookOrder := range orderbook.Orders { + if orderbookOrder.Error != nil { + o.config.Logger.Warn( + "error processing orderbook", + zap.String("orderbook", orderbook.Orderbook.ContractAddress), + zap.Int64("tick", orderbookOrder.Tick.Tick.TickId), + zap.Error(err), + ) + continue + } + + for _, order := range orderbookOrder.Orders { + if order.Error != nil { + o.config.Logger.Warn( + "unable to create orderbook limit order; marking as not claimable", + zap.String("orderbook", orderbook.Orderbook.ContractAddress), + zap.Int64("tick", orderbookOrder.Tick.Tick.TickId), + zap.Error(err), + ) + continue + } + + claimable = append(claimable, order.Order) + } + } + + if err := o.processOrderbookOrders(ctx, account, orderbook.Orderbook, claimable); err != nil { + o.config.Logger.Warn( + "failed to process orderbook orders", + zap.String("contract_address", orderbook.Orderbook.ContractAddress), + zap.Error(err), + ) + } + } + + return nil +} + +// processOrderbookOrders processes a batch of claimable orders. +func (o *claimbot) processOrderbookOrders(ctx context.Context, account *authtypes.BaseAccount, orderbook domain.CanonicalOrderBooksResult, orders orderbookdomain.Orders) error { + if len(orders) == 0 { + return nil + } + + for _, chunk := range slices.Split(orders, maxBatchOfClaimableOrders) { + if len(chunk) == 0 { + continue + } + + txres, err := sendBatchClaimTx( + ctx, + o.config.Keyring, + o.config.TxfeesClient, + o.config.GasCalculator, + o.config.TxServiceClient, + o.config.ChainID, + account, + orderbook.ContractAddress, + chunk, + ) + + if err != nil || (txres != nil && txres.Code != 0) { + o.config.Logger.Info("failed sending tx", + zap.String("orderbook contract address", orderbook.ContractAddress), + zap.Any("orders", chunk), + zap.Any("tx result", txres), + zap.Error(err), + ) + + // if the tx failed, we need to fetch the account again to get the latest sequence number. + account, err = o.config.AccountQueryClient.GetAccount(ctx, o.config.Keyring.GetAddress().String()) + if err != nil { + return err + } + + continue // continue processing the next batch + } + + // Since we have a lock on block processing, that is, if block X is being processed, + // block X+1 processing cannot start, instead of waiting for the tx to be included + // in the block we set the sequence number here to avoid sequence number mismatch errors. + if err := account.SetSequence(account.GetSequence() + 1); err != nil { + o.config.Logger.Info("failed incrementing account sequence number", + zap.String("orderbook contract address", orderbook.ContractAddress), + zap.Error(err), + ) + } + } + + return nil +} diff --git a/ingest/usecase/plugins/orderbook/claimbot/tx.go b/ingest/usecase/plugins/orderbook/claimbot/tx.go new file mode 100644 index 000000000..7456ebde0 --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/tx.go @@ -0,0 +1,99 @@ +package claimbot + +import ( + "context" + "encoding/json" + "fmt" + + sqstx "github.com/osmosis-labs/sqs/domain/cosmos/tx" + "github.com/osmosis-labs/sqs/domain/keyring" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" + + "github.com/osmosis-labs/osmosis/v26/app" + txfeestypes "github.com/osmosis-labs/osmosis/v26/x/txfees/types" + + wasmtypes "github.com/CosmWasm/wasmd/x/wasm/types" + sdk "github.com/cosmos/cosmos-sdk/types" + txtypes "github.com/cosmos/cosmos-sdk/types/tx" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" +) + +var ( + encodingConfig = app.MakeEncodingConfig() +) + +// sendBatchClaimTx prepares and sends a batch claim transaction to the blockchain. +// It builds the transaction, signs it, and broadcasts it to the network. +func sendBatchClaimTx( + ctx context.Context, + keyring keyring.Keyring, + txfeesClient txfeestypes.QueryClient, + gasCalculator sqstx.GasCalculator, + txServiceClient txtypes.ServiceClient, + chainID string, + account *authtypes.BaseAccount, + contractAddress string, + claims orderbookdomain.Orders, +) (*sdk.TxResponse, error) { + address := keyring.GetAddress().String() + + msgBytes, err := prepareBatchClaimMsg(claims) + if err != nil { + return nil, err + } + + msg := buildExecuteContractMsg(address, contractAddress, msgBytes) + + tx, err := sqstx.BuildTx(ctx, keyring, txfeesClient, gasCalculator, encodingConfig, account, chainID, msg) + if err != nil { + return nil, fmt.Errorf("failed to build transaction: %w", err) + } + + txBytes, err := encodingConfig.TxConfig.TxEncoder()(tx.GetTx()) + if err != nil { + return nil, fmt.Errorf("failed to encode transaction: %w", err) + } + + return sqstx.SendTx(ctx, txServiceClient, txBytes) +} + +// batchClaim represents batch claim orders message. +type batchClaim struct { + batchClaimOrders `json:"batch_claim"` +} + +// batchClaimOrders represents the orders in the batch claim message. +// Each order is represented by a pair of tick ID and order ID. +type batchClaimOrders struct { + Orders [][]int64 `json:"orders"` +} + +// prepareBatchClaimMsg creates a JSON-encoded batch claim message from the provided orders. +func prepareBatchClaimMsg(claims orderbookdomain.Orders) ([]byte, error) { + orders := make([][]int64, len(claims)) + for i, claim := range claims { + orders[i] = []int64{claim.TickId, claim.OrderId} + } + + batchClaim := batchClaim{ + batchClaimOrders: batchClaimOrders{ + Orders: orders, + }, + } + + msgBytes, err := json.Marshal(batchClaim) + if err != nil { + return nil, fmt.Errorf("failed to marshal message: %w", err) + } + return msgBytes, nil +} + +// buildExecuteContractMsg constructs a message for executing a smart contract. +func buildExecuteContractMsg(address, contractAddress string, msgBytes []byte) *wasmtypes.MsgExecuteContract { + return &wasmtypes.MsgExecuteContract{ + Sender: address, + Contract: contractAddress, + Msg: msgBytes, + Funds: sdk.NewCoins(), + } +} diff --git a/ingest/usecase/plugins/orderbook/claimbot/tx_test.go b/ingest/usecase/plugins/orderbook/claimbot/tx_test.go new file mode 100644 index 000000000..79bc6c652 --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/tx_test.go @@ -0,0 +1,161 @@ +package claimbot_test + +import ( + "context" + "testing" + + "github.com/osmosis-labs/sqs/domain/mocks" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" + "github.com/osmosis-labs/sqs/ingest/usecase/plugins/orderbook/claimbot" + + 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/stretchr/testify/assert" + "google.golang.org/grpc" +) + +func TestSendBatchClaimTx(t *testing.T) { + tests := []struct { + name string + chainID string + contractAddress string + claims orderbookdomain.Orders + setupMocks func(*mocks.Keyring, *authtypes.BaseAccount, *mocks.TxFeesQueryClient, *mocks.GasCalculator, *mocks.TxServiceClient) + setSendTxFunc func() []byte + expectedResponse *sdk.TxResponse + expectedError bool + }{ + { + name: "BuildTx returns error", + contractAddress: "osmo1contractaddress", + claims: orderbookdomain.Orders{ + {TickId: 13, OrderId: 99}, + }, + setupMocks: func(keyringMock *mocks.Keyring, account *authtypes.BaseAccount, txfeesClient *mocks.TxFeesQueryClient, gasCalculator *mocks.GasCalculator, txServiceClient *mocks.TxServiceClient) { + keyringMock.WithGetAddress("osmo0address") + keyringMock.WithGetKey("6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159") + account = &authtypes.BaseAccount{ + AccountNumber: 3, + Sequence: 31, + } + gasCalculator.WithCalculateGas(nil, 0, assert.AnError) // Fail BuildTx + }, + expectedResponse: &sdk.TxResponse{}, + expectedError: true, + }, + { + name: "SendTx returns error", + contractAddress: "osmo1contractaddress", + claims: orderbookdomain.Orders{ + {TickId: 13, OrderId: 99}, + }, + setupMocks: func(keyringMock *mocks.Keyring, account *authtypes.BaseAccount, txfeesClient *mocks.TxFeesQueryClient, gasCalculator *mocks.GasCalculator, txServiceClient *mocks.TxServiceClient) { + keyringMock.WithGetAddress("osmo5address") + keyringMock.WithGetKey("6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159") + gasCalculator.WithCalculateGas(nil, 51, nil) + txfeesClient.WithBaseDenom("uosmo", nil) + txfeesClient.WithGetEipBaseFee("0.2", nil) + account = &authtypes.BaseAccount{ + AccountNumber: 83, + Sequence: 5, + } + txServiceClient.WithBroadcastTx(nil, assert.AnError) // SendTx returns error + }, + expectedResponse: &sdk.TxResponse{}, + expectedError: true, + }, + { + name: "Successful transaction", + chainID: "osmosis-1", + contractAddress: "osmo1contractaddress", + claims: orderbookdomain.Orders{ + {TickId: 1, OrderId: 100}, + {TickId: 2, OrderId: 200}, + }, + setupMocks: func(keyringMock *mocks.Keyring, account *authtypes.BaseAccount, txfeesClient *mocks.TxFeesQueryClient, gasCalculator *mocks.GasCalculator, txServiceClient *mocks.TxServiceClient) { + keyringMock.WithGetAddress("osmo1address") + keyringMock.WithGetKey("6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159") + gasCalculator.WithCalculateGas(nil, 51, nil) + txfeesClient.WithBaseDenom("uosmo", nil) + txfeesClient.WithGetEipBaseFee("0.15", nil) + account = &authtypes.BaseAccount{ + AccountNumber: 1, + Sequence: 1, + } + + txServiceClient.BroadcastTxFunc = func(ctx context.Context, in *txtypes.BroadcastTxRequest, opts ...grpc.CallOption) (*txtypes.BroadcastTxResponse, error) { + return &txtypes.BroadcastTxResponse{ + TxResponse: &sdk.TxResponse{ + Data: string(in.TxBytes), // Assigning the txBytes to response Data to compare it later + }, + }, nil + } + }, + expectedResponse: &sdk.TxResponse{ + Data: "\n\x90\x01\n\x8d\x01\n$/cosmwasm.wasm.v1.MsgExecuteContract\x12e\n\x1fosmo1daek6me3v9jxgun9wdes7m4n5q\x12\x14osmo1contractaddress\x1a,{\"batch_claim\":{\"orders\":[[1,100],[2,200]]}}\x12`\nN\nF\n\x1f/cosmos.crypto.secp256k1.PubKey\x12#\n!\x03\xef]m\xf2\x8a\bx\x1f\x9a%v]E\x9e\x96\xa8\x9dc6a\x1d\x1f\x8a\xb4\xd3/q,֍\xd3\xd0\x12\x04\n\x02\b\x01\x12\x0e\n\n\n\x05uosmo\x12\x018\x103\x1a@\x1dI\xb5/D\xd0L\v2\xacg\x91\xb3;b+\xdb\xf6\xe0\x1c\x92\xee\xb8d\xc4&%<ڵ\x81\xd6u\xeb-\xf0ੌ\xf5\xa8);\x19\xfc%@\r\xfb2\x05AI\x13\xf3)=\n\xcf~\xb0\"\xf0\xb1", + }, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + keyring := mocks.Keyring{} + account := authtypes.BaseAccount{} + txFeesClient := mocks.TxFeesQueryClient{} + gasCalculator := mocks.GasCalculator{} + txServiceClient := mocks.TxServiceClient{} + + tt.setupMocks(&keyring, &account, &txFeesClient, &gasCalculator, &txServiceClient) + + response, err := claimbot.SendBatchClaimTx(ctx, &keyring, &txFeesClient, &gasCalculator, &txServiceClient, tt.chainID, &account, tt.contractAddress, tt.claims) + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedResponse, response) + } + }) + } +} + +func TestPrepareBatchClaimMsg(t *testing.T) { + tests := []struct { + name string + claims orderbookdomain.Orders + want []byte + }{ + { + name: "Single claim", + claims: orderbookdomain.Orders{ + {TickId: 1, OrderId: 100}, + }, + want: []byte(`{"batch_claim":{"orders":[[1,100]]}}`), + }, + { + name: "Multiple claims", + claims: orderbookdomain.Orders{ + {TickId: 1, OrderId: 100}, + {TickId: 2, OrderId: 200}, + {TickId: 3, OrderId: 300}, + }, + want: []byte(`{"batch_claim":{"orders":[[1,100],[2,200],[3,300]]}}`), + }, + { + name: "Empty claims", + claims: orderbookdomain.Orders{}, + want: []byte(`{"batch_claim":{"orders":[]}}`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := claimbot.PrepareBatchClaimMsg(tt.claims) + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/ingest/usecase/plugins/orderbook/fillbot/README.md b/ingest/usecase/plugins/orderbook/fillbot/README.md index af669959c..fd3973f0f 100644 --- a/ingest/usecase/plugins/orderbook/fillbot/README.md +++ b/ingest/usecase/plugins/orderbook/fillbot/README.md @@ -45,7 +45,7 @@ In `config.json`, set the plugin to enabled: ... "plugins": [ { - "name": "orderbook", + "name": "orderbook-fillbot-plugin", "enabled": true } ] diff --git a/ingest/usecase/plugins/orderbook/fillbot/create_copy_config.sh b/ingest/usecase/plugins/orderbook/fillbot/create_copy_config.sh index 01d6ae4a9..6bc66a52c 100755 --- a/ingest/usecase/plugins/orderbook/fillbot/create_copy_config.sh +++ b/ingest/usecase/plugins/orderbook/fillbot/create_copy_config.sh @@ -1,9 +1,19 @@ -#!/bin/bash +#!/usr/bin/env bash # Define the input and output file paths ORIGINAL_APP_TOML_NAME="$HOME/.osmosisd/config/app.toml" # Replace with the actual file path BACKUP_APP_TOML_NAME="$HOME/.osmosisd/config/app-backup.toml" +if [ ! -f "$ORIGINAL_APP_TOML_NAME" ]; then + echo "Error: Source file $ORIGINAL_APP_TOML_NAME does not exist." + exit 1 +fi + +if [ -f $BACKUP_APP_TOML_NAME ]; then + echo "Backup file $BACKUP_APP_TOML_NAME already exist, no modifications will be made." + exit 0 +fi + mv $ORIGINAL_APP_TOML_NAME $BACKUP_APP_TOML_NAME # Use sed to modify the TOML and create a new file diff --git a/log/logger.go b/log/logger.go index 087458635..9a8332ab3 100644 --- a/log/logger.go +++ b/log/logger.go @@ -8,6 +8,7 @@ import ( ) type Logger interface { + Named(s string) Logger Info(msg string, fields ...zap.Field) Warn(msg string, fields ...zap.Field) Error(msg string, fields ...zap.Field) @@ -36,6 +37,11 @@ func (*NoOpLogger) Warn(msg string, fields ...zapcore.Field) { // no-op } +// Warn implements Logger. +func (l *NoOpLogger) Named(s string) Logger { + return l +} + var _ Logger = (*NoOpLogger)(nil) type loggerImpl struct { @@ -64,6 +70,12 @@ func (l *loggerImpl) Warn(msg string, fields ...zapcore.Field) { l.zapLogger.Warn(msg, fields...) } +func (l *loggerImpl) Named(s string) Logger { + return &loggerImpl{ + zapLogger: *l.zapLogger.Named(s), + } +} + // NewLogger creates a new logger. // If fileName is non-empty, it pipes logs to file and stdout. // if filename is empty, it pipes logs only to stdout. @@ -113,5 +125,7 @@ func NewLogger(isProduction bool, fileName string, logLevelStr string) (Logger, logger.Info("log level", zap.Bool("is_debug", isDebugLevel), zap.String("log_level", loggerConfig.Level.String())) - return logger, nil + return &loggerImpl{ + zapLogger: *logger, + }, nil } diff --git a/orderbook/usecase/orderbook_usecase.go b/orderbook/usecase/orderbook_usecase.go index 78c6bc31d..a0227d911 100644 --- a/orderbook/usecase/orderbook_usecase.go +++ b/orderbook/usecase/orderbook_usecase.go @@ -501,3 +501,96 @@ func (o *OrderbookUseCaseImpl) CreateFormattedLimitOrder(orderbook domain.Canoni PlacedAt: placedAt, }, nil } + +func (o *OrderbookUseCaseImpl) GetClaimableOrdersForOrderbook(ctx context.Context, fillThreshold osmomath.Dec, orderbook domain.CanonicalOrderBooksResult) ([]orderbookdomain.ClaimableOrderbook, error) { + ticks, ok := o.orderbookRepository.GetAllTicks(orderbook.PoolID) + if !ok { + return nil, fmt.Errorf("no ticks found for orderbook %s with pool %d", orderbook.ContractAddress, orderbook.PoolID) + } + + var orders []orderbookdomain.ClaimableOrderbook + for _, tick := range ticks { + tickOrders, err := o.getClaimableOrdersForTick(ctx, fillThreshold, orderbook, tick) + orders = append(orders, orderbookdomain.ClaimableOrderbook{ + Tick: tick, + Orders: tickOrders, + Error: err, + }) + } + + return orders, nil +} + +// getClaimableOrdersForTick retrieves claimable orders for a specific tick in an orderbook +// It processes all ask/bid direction orders and filters the orders that are claimable. +func (o *OrderbookUseCaseImpl) getClaimableOrdersForTick( + ctx context.Context, + fillThreshold osmomath.Dec, + orderbook domain.CanonicalOrderBooksResult, + tick orderbookdomain.OrderbookTick, +) ([]orderbookdomain.ClaimableOrder, error) { + orders, err := o.orderBookClient.GetOrdersByTick(ctx, orderbook.ContractAddress, tick.Tick.TickId) + if err != nil { + return nil, err + } + + if len(orders) == 0 { + return nil, nil // nothing to process + } + + askClaimable, err := o.getClaimableOrders(orderbook, orders.OrderByDirection("ask"), tick.TickState.AskValues, fillThreshold) + if err != nil { + return nil, err + } + + bidClaimable, err := o.getClaimableOrders(orderbook, orders.OrderByDirection("bid"), tick.TickState.BidValues, fillThreshold) + if err != nil { + return nil, err + } + + return append(askClaimable, bidClaimable...), nil +} + +// getClaimableOrders determines which orders are claimable for a given direction (ask or bid) in a tick. +// If the tick is fully filled, all orders are considered claimable. Otherwise, it filters the orders +// based on the fill threshold. +func (o *OrderbookUseCaseImpl) getClaimableOrders( + orderbook domain.CanonicalOrderBooksResult, + orders orderbookdomain.Orders, + tickValues orderbookdomain.TickValues, + fillThreshold osmomath.Dec, +) ([]orderbookdomain.ClaimableOrder, error) { + isFilled, err := tickValues.IsTickFullyFilled() + if err != nil { + return nil, err + } + + var result []orderbookdomain.ClaimableOrder + for _, order := range orders { + if isFilled { + result = append(result, orderbookdomain.ClaimableOrder{Order: order}) + continue + } + claimable, err := o.isOrderClaimable(orderbook, order, fillThreshold) + orderToAdd := orderbookdomain.ClaimableOrder{Order: order, Error: err} + + if err != nil || claimable { + result = append(result, orderToAdd) + } + } + + return result, nil +} + +// isOrderClaimable determines if a single order is claimable based on the fill threshold. +func (o *OrderbookUseCaseImpl) isOrderClaimable( + orderbook domain.CanonicalOrderBooksResult, + order orderbookdomain.Order, + fillThreshold osmomath.Dec, +) (bool, error) { + result, err := o.CreateFormattedLimitOrder(orderbook, order) + if err != nil { + return false, err + } + return result.IsClaimable(fillThreshold), nil +} diff --git a/orderbook/usecase/orderbook_usecase_test.go b/orderbook/usecase/orderbook_usecase_test.go index fd063605b..645b84f6a 100644 --- a/orderbook/usecase/orderbook_usecase_test.go +++ b/orderbook/usecase/orderbook_usecase_test.go @@ -1074,3 +1074,120 @@ func (s *OrderbookUsecaseTestSuite) TestCreateFormattedLimitOrder() { }) } } + +func (s *OrderbookUsecaseTestSuite) TestGetClaimableOrdersForOrderbook() { + + newOrder := func(id int64, direction string) orderbookdomain.Order { + order := s.NewOrder() + order.OrderId = id + order.OrderDirection = direction + return order.Order + } + + testCases := []struct { + name string + setupMocks func(orderbookrepository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, tokensusecase *mocks.TokensUsecaseMock) + orderbook domain.CanonicalOrderBooksResult + fillThreshold osmomath.Dec + expectedOrders []orderbookdomain.ClaimableOrderbook + expectedError bool + }{ + { + name: "no ticks found for orderbook", + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetAllTicksFunc = func(poolID uint64) (map[int64]orderbookdomain.OrderbookTick, bool) { + return nil, false + } + }, + orderbook: domain.CanonicalOrderBooksResult{PoolID: 1, ContractAddress: "osmo1contract"}, + fillThreshold: osmomath.NewDec(80), + expectedError: true, + }, + { + name: "error processing tick", + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetAllTicksFunc = func(poolID uint64) (map[int64]orderbookdomain.OrderbookTick, bool) { + return map[int64]orderbookdomain.OrderbookTick{ + 1: s.NewTick("500", 100, "bid"), + }, true + } + client.GetOrdersByTickCb = func(ctx context.Context, contractAddress string, tickID int64) (orderbookdomain.Orders, error) { + return nil, assert.AnError + } + }, + orderbook: domain.CanonicalOrderBooksResult{PoolID: 1, ContractAddress: "osmo1contract"}, + fillThreshold: osmomath.NewDec(80), + expectedOrders: []orderbookdomain.ClaimableOrderbook{ + { + Tick: s.NewTick("500", 100, "bid"), + Orders: nil, + Error: assert.AnError, + }, + }, + expectedError: false, + }, + { + name: "successful retrieval of claimable orders", + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, tokensusecase *mocks.TokensUsecaseMock) { + + tokensusecase.GetSpotPriceScalingFactorByDenomFunc = s.GetSpotPriceScalingFactorByDenomFunc(1, nil) + orderbookrepository.GetAllTicksFunc = func(poolID uint64) (map[int64]orderbookdomain.OrderbookTick, bool) { + return map[int64]orderbookdomain.OrderbookTick{ + 1: s.NewTick("500", 100, "all"), + }, true + } + client.GetOrdersByTickCb = func(ctx context.Context, contractAddress string, tickID int64) (orderbookdomain.Orders, error) { + return orderbookdomain.Orders{ + newOrder(1, "bid"), + newOrder(2, "bid"), + }, nil + } + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("500", 100, "bid"), true) + }, + orderbook: domain.CanonicalOrderBooksResult{PoolID: 1, ContractAddress: "osmo1contract"}, + fillThreshold: osmomath.MustNewDecFromStr("0.3"), + expectedOrders: []orderbookdomain.ClaimableOrderbook{ + { + Tick: s.NewTick("500", 100, "all"), + Orders: []orderbookdomain.ClaimableOrder{ + { + Order: newOrder(1, "bid"), + }, + { + Order: newOrder(2, "bid"), + }, + }, + Error: nil, + }, + }, + expectedError: false, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + // Create instances of the mocks + orderbookrepository := mocks.OrderbookRepositoryMock{} + client := mocks.OrderbookGRPCClientMock{} + tokensusecase := mocks.TokensUsecaseMock{} + + if tc.setupMocks != nil { + tc.setupMocks(&orderbookrepository, &client, &tokensusecase) + } + + // Setup the mocks according to the test case + usecase := orderbookusecase.New(&orderbookrepository, &client, nil, &tokensusecase, &log.NoOpLogger{}) + + // Call the method under test + orders, err := usecase.GetClaimableOrdersForOrderbook(context.Background(), tc.fillThreshold, tc.orderbook) + + // Assert the results + if tc.expectedError { + s.Assert().Error(err) + } else { + s.Assert().NoError(err) + s.Assert().Equal(tc.expectedOrders, orders) + } + }) + } +} diff --git a/orderbook/usecase/orderbooktesting/suite.go b/orderbook/usecase/orderbooktesting/suite.go index c256e5566..3bf0974b9 100644 --- a/orderbook/usecase/orderbooktesting/suite.go +++ b/orderbook/usecase/orderbooktesting/suite.go @@ -8,6 +8,7 @@ import ( "github.com/osmosis-labs/sqs/domain" orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" "github.com/osmosis-labs/sqs/router/usecase/routertesting" + "github.com/osmosis-labs/sqs/sqsdomain/cosmwasmpool" "github.com/osmosis-labs/osmosis/osmomath" ) @@ -114,23 +115,37 @@ func (s *OrderbookTestHelper) NewTick(effectiveTotalAmountSwapped string, unreal tickValues := orderbookdomain.TickValues{ EffectiveTotalAmountSwapped: effectiveTotalAmountSwapped, + CumulativeTotalValue: "1000", } tick := orderbookdomain.OrderbookTick{ TickState: orderbookdomain.TickState{}, UnrealizedCancels: orderbookdomain.UnrealizedCancels{}, } - - if direction == "bid" { + switch direction { + case "bid": tick.TickState.BidValues = tickValues if unrealizedCancels != 0 { tick.UnrealizedCancels.BidUnrealizedCancels = osmomath.NewInt(unrealizedCancels) } - } else { + case "ask": tick.TickState.AskValues = tickValues if unrealizedCancels != 0 { tick.UnrealizedCancels.AskUnrealizedCancels = osmomath.NewInt(unrealizedCancels) } + case "all": + tick.TickState.AskValues = tickValues + tick.TickState.BidValues = tickValues + if unrealizedCancels != 0 { + tick.UnrealizedCancels.AskUnrealizedCancels = osmomath.NewInt(unrealizedCancels) + tick.UnrealizedCancels.BidUnrealizedCancels = osmomath.NewInt(unrealizedCancels) + } + default: + s.T().Fatalf("invalid direction: %s", direction) + } + + tick.Tick = &cosmwasmpool.OrderbookTick{ + TickLiquidity: cosmwasmpool.OrderbookTickLiquidity{}, } return tick diff --git a/router/repository/memory_router_repository_test.go b/router/repository/memory_router_repository_test.go index 22b4c6a99..64a13da2c 100644 --- a/router/repository/memory_router_repository_test.go +++ b/router/repository/memory_router_repository_test.go @@ -3,13 +3,13 @@ package routerrepo_test import ( "testing" - "github.com/alecthomas/assert/v2" "github.com/osmosis-labs/osmosis/osmomath" "github.com/osmosis-labs/sqs/domain" "github.com/osmosis-labs/sqs/domain/mocks" "github.com/osmosis-labs/sqs/log" routerrepo "github.com/osmosis-labs/sqs/router/repository" "github.com/osmosis-labs/sqs/sqsdomain" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) From 662aac45911e245a74d16a7622fb703010571286 Mon Sep 17 00:00:00 2001 From: Roman Date: Sun, 3 Nov 2024 12:42:19 -0800 Subject: [PATCH 04/12] refactor: gas estimation APIs (#546) * refactor: gas estimation APIs * changelog * lint --- CHANGELOG.md | 5 + domain/cosmos/tx/gas.go | 43 ----- domain/cosmos/tx/msg_simulator.go | 162 ++++++++++++++++++ domain/cosmos/tx/msg_simulator_test.go | 160 +++++++++++++++++ domain/cosmos/tx/tx.go | 102 ----------- domain/cosmos/tx/tx_test.go | 143 ---------------- domain/mocks/calculate_gas_mock.go | 17 ++ domain/mocks/gas_calculator.go | 25 --- domain/mocks/msg_simulator_mock.go | 61 +++++++ domain/mocks/tx_builder_mock.go | 131 ++++++++++++++ domain/mocks/tx_config_mock.go | 68 ++++++++ domain/mocks/tx_mock.go | 135 +++++++++++++++ .../plugins/orderbook/claimbot/config.go | 4 +- .../plugins/orderbook/claimbot/export_test.go | 14 +- .../plugins/orderbook/claimbot/plugin.go | 2 +- .../usecase/plugins/orderbook/claimbot/tx.go | 36 +++- .../plugins/orderbook/claimbot/tx_test.go | 116 ++++++++++--- 17 files changed, 874 insertions(+), 350 deletions(-) delete mode 100644 domain/cosmos/tx/gas.go create mode 100644 domain/cosmos/tx/msg_simulator.go create mode 100644 domain/cosmos/tx/msg_simulator_test.go create mode 100644 domain/mocks/calculate_gas_mock.go delete mode 100644 domain/mocks/gas_calculator.go create mode 100644 domain/mocks/msg_simulator_mock.go create mode 100644 domain/mocks/tx_builder_mock.go create mode 100644 domain/mocks/tx_config_mock.go create mode 100644 domain/mocks/tx_mock.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 844ceb7e1..b99a9864b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,11 @@ Ref: https://keepachangelog.com/en/1.0.0/ # Changelog +## Unreleased + +- #526 - Refactor gas estimation APIs +- #524 - Claimbot + ## v26.1.0 e42b32bc SQS-412 | Active Orders Query: SSE (#518) diff --git a/domain/cosmos/tx/gas.go b/domain/cosmos/tx/gas.go deleted file mode 100644 index 935f392d8..000000000 --- a/domain/cosmos/tx/gas.go +++ /dev/null @@ -1,43 +0,0 @@ -package tx - -import ( - txclient "github.com/cosmos/cosmos-sdk/client/tx" - sdk "github.com/cosmos/cosmos-sdk/types" - txtypes "github.com/cosmos/cosmos-sdk/types/tx" - - gogogrpc "github.com/cosmos/gogoproto/grpc" -) - -// GasCalculator is an interface for calculating gas for a transaction. -type GasCalculator interface { - CalculateGas(txf txclient.Factory, msgs ...sdk.Msg) (*txtypes.SimulateResponse, uint64, error) -} - -// NewGasCalculator creates a new GasCalculator instance. -func NewGasCalculator(clientCtx gogogrpc.ClientConn) GasCalculator { - return &TxGasCalulator{ - clientCtx: clientCtx, - } -} - -// TxGasCalulator is a GasCalculator implementation that uses simulated transactions to calculate gas. -type TxGasCalulator struct { - clientCtx gogogrpc.ClientConn -} - -// CalculateGas calculates the gas required for a transaction using the provided transaction factory and messages. -func (c *TxGasCalulator) CalculateGas( - txf txclient.Factory, - msgs ...sdk.Msg, -) (*txtypes.SimulateResponse, uint64, error) { - gasResult, adjustedGasUsed, err := txclient.CalculateGas( - c.clientCtx, - txf, - msgs..., - ) - if err != nil { - return nil, adjustedGasUsed, err - } - - return gasResult, adjustedGasUsed, nil -} diff --git a/domain/cosmos/tx/msg_simulator.go b/domain/cosmos/tx/msg_simulator.go new file mode 100644 index 000000000..0778fffd0 --- /dev/null +++ b/domain/cosmos/tx/msg_simulator.go @@ -0,0 +1,162 @@ +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) +} + +// 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 + } + + _, gas, err := c.SimulateMsgs( + 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)) + + 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 +} + +// 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..20849e3ab --- /dev/null +++ b/domain/cosmos/tx/msg_simulator_test.go @@ -0,0 +1,160 @@ +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/sqs/domain/cosmos/tx" + "github.com/osmosis-labs/sqs/domain/mocks" + "github.com/stretchr/testify/assert" +) + +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: &authtypes.BaseAccount{AccountNumber: 1, Sequence: 1}, + chainID: "test-chain", + msgs: []sdk.Msg{newMsg("sender", "contract", `{}`)}, + setupMocks: func(calculator mocks.GetCalculateGasMock) tx.CalculateGasFn { + return calculator(&txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, 50, nil) + }, + expectedSimulateResponse: &txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, + expectedGas: 50, + expectedError: nil, + }, + { + name: "Simulation error", + account: &authtypes.BaseAccount{AccountNumber: 2, Sequence: 2}, + chainID: "test-chain", + msgs: []sdk.Msg{}, + setupMocks: func(calculator mocks.GetCalculateGasMock) tx.CalculateGasFn { + return calculator(&txtypes.SimulateResponse{}, 3, assert.AnError) + }, + expectedSimulateResponse: nil, + expectedGas: 3, + expectedError: assert.AnError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup the mock + calculateGasFnMock := tt.setupMocks(mocks.DefaultGetCalculateGasMock) + + // Create the gas calculator + gasCalculator := tx.NewGasCalculator(nil, calculateGasFnMock) + + // Call the function + result, gas, err := gasCalculator.SimulateMsgs( + encodingConfig.TxConfig, + tt.account, + tt.chainID, + tt.msgs, + ) + + // Assert the results + 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("6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159") + txFeesClient.WithBaseDenom("eth", nil) + txFeesClient.WithGetEipBaseFee("0.1", nil) + + return calculator(&txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, 50, nil) + }, + account: &authtypes.BaseAccount{ + Sequence: 13, + AccountNumber: 1, + }, + chainID: "test-chain", + msgs: []sdk.Msg{newMsg("sender", "contract", `{"payload": "hello contract"}`)}, + expectedJSON: []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=="]}`), + expectedError: false, + }, + { + name: "Error building transaction", + setupMocks: func(calculator mocks.GetCalculateGasMock, txFeesClient *mocks.TxFeesQueryClient, keyring *mocks.Keyring) tx.CalculateGasFn { + keyring.WithGetKey("6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159") + + return calculator(&txtypes.SimulateResponse{}, 50, 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{} + + // Setup the mock + calculateGasFnMock := tc.setupMocks(mocks.DefaultGetCalculateGasMock, &txFeesClient, &keyring) + + // Create the gas calculator + 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) + + // Add more specific assertions here based on the expected output + assert.Equal(t, string(tc.expectedJSON), string(txJSONBytes)) + } + }) + } +} diff --git a/domain/cosmos/tx/tx.go b/domain/cosmos/tx/tx.go index 5c7353e0e..aad0f4760 100644 --- a/domain/cosmos/tx/tx.go +++ b/domain/cosmos/tx/tx.go @@ -4,89 +4,16 @@ package tx import ( "context" - "github.com/osmosis-labs/sqs/domain/keyring" - "github.com/osmosis-labs/osmosis/osmomath" - "github.com/osmosis-labs/osmosis/v26/app/params" txfeestypes "github.com/osmosis-labs/osmosis/v26/x/txfees/types" - cosmosClient "github.com/cosmos/cosmos-sdk/client" - txclient "github.com/cosmos/cosmos-sdk/client/tx" - "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" sdk "github.com/cosmos/cosmos-sdk/types" txtypes "github.com/cosmos/cosmos-sdk/types/tx" signingtypes "github.com/cosmos/cosmos-sdk/types/tx/signing" authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" - authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" ) -// BuildTx constructs a transaction using the provided parameters and messages. -// Returns a TxBuilder and any error encountered. -func BuildTx( - ctx context.Context, - keyring keyring.Keyring, - txfeesClient txfeestypes.QueryClient, - gasCalculator GasCalculator, - 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 - } - - _, gas, err := SimulateMsgs( - gasCalculator, - encodingConfig, - 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)) - - 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 -} - // SendTx broadcasts a transaction to the chain, returning the result and error. func SendTx(ctx context.Context, txServiceClient txtypes.ServiceClient, txBytes []byte) (*sdk.TxResponse, error) { // We then call the BroadcastTx method on this client. @@ -104,35 +31,6 @@ func SendTx(ctx context.Context, txServiceClient txtypes.ServiceClient, txBytes return resp.TxResponse, nil } -// 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. -func SimulateMsgs( - gasCalculator GasCalculator, - encodingConfig params.EncodingConfig, - account *authtypes.BaseAccount, - chainID string, - msgs []sdk.Msg, -) (*txtypes.SimulateResponse, uint64, error) { - txFactory := txclient.Factory{} - txFactory = txFactory.WithTxConfig(encodingConfig.TxConfig) - txFactory = txFactory.WithAccountNumber(account.AccountNumber) - txFactory = txFactory.WithSequence(account.Sequence) - txFactory = txFactory.WithChainID(chainID) - txFactory = txFactory.WithGasAdjustment(1.15) - - // Estimate transaction - gasResult, adjustedGasUsed, err := gasCalculator.CalculateGas( - txFactory, - msgs..., - ) - if err != nil { - return nil, adjustedGasUsed, err - } - - return gasResult, adjustedGasUsed, nil -} - // BuildSignatures creates a SignatureV2 object using the provided public key, signature, and sequence number. // This is used in the process of building and signing transactions. func BuildSignatures(publicKey cryptotypes.PubKey, signature []byte, sequence uint64) signingtypes.SignatureV2 { diff --git a/domain/cosmos/tx/tx_test.go b/domain/cosmos/tx/tx_test.go index a48857d5a..615b970a6 100644 --- a/domain/cosmos/tx/tx_test.go +++ b/domain/cosmos/tx/tx_test.go @@ -17,7 +17,6 @@ import ( txtypes "github.com/cosmos/cosmos-sdk/types/tx" signingtypes "github.com/cosmos/cosmos-sdk/types/tx/signing" authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" - authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" wasmtypes "github.com/CosmWasm/wasmd/x/wasm/types" @@ -38,83 +37,6 @@ var ( } ) -func TestBuildTx(t *testing.T) { - testCases := []struct { - name string - setupMocks func(calculator *mocks.GasCalculator, txFeesClient *mocks.TxFeesQueryClient, keyring *mocks.Keyring) - account *authtypes.BaseAccount - chainID string - msgs []sdk.Msg - expectedJSON []byte - expectedError bool - }{ - { - name: "Valid transaction", - setupMocks: func(calculator *mocks.GasCalculator, txFeesClient *mocks.TxFeesQueryClient, keyring *mocks.Keyring) { - calculator.WithCalculateGas(nil, 50, nil) - keyring.WithGetKey("6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159") - txFeesClient.WithBaseDenom("eth", nil) - txFeesClient.WithGetEipBaseFee("0.1", nil) - }, - account: &authtypes.BaseAccount{ - Sequence: 13, - AccountNumber: 1, - }, - chainID: "test-chain", - msgs: []sdk.Msg{newMsg("sender", "contract", `{"payload": "hello contract"}`)}, - expectedJSON: []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=="]}`), - expectedError: false, - }, - { - name: "Error building transaction", - setupMocks: func(calculator *mocks.GasCalculator, txFeesClient *mocks.TxFeesQueryClient, keyring *mocks.Keyring) { - calculator.WithCalculateGas(nil, 50, assert.AnError) - keyring.WithGetKey("6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159") - }, - account: &authtypes.BaseAccount{ - Sequence: 8, - AccountNumber: 51, - }, - expectedError: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - gasCalculator := mocks.GasCalculator{} - txFeesClient := mocks.TxFeesQueryClient{} - keyring := mocks.Keyring{} - - tc.setupMocks(&gasCalculator, &txFeesClient, &keyring) - - txBuilder, err := sqstx.BuildTx( - context.Background(), - &keyring, - &txFeesClient, - &gasCalculator, - 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) - - // Add more specific assertions here based on the expected output - assert.Equal(t, string(tc.expectedJSON), string(txJSONBytes)) - } - }) - } -} - func TestSendTx(t *testing.T) { newBroadcastTxFunc := func(txResponse *txtypes.BroadcastTxResponse, err error) func(ctx context.Context, in *txtypes.BroadcastTxRequest, opts ...grpc.CallOption) (*txtypes.BroadcastTxResponse, error) { return func(ctx context.Context, in *txtypes.BroadcastTxRequest, opts ...grpc.CallOption) (*txtypes.BroadcastTxResponse, error) { @@ -163,71 +85,6 @@ func TestSendTx(t *testing.T) { } } -func TestSimulateMsgs(t *testing.T) { - tests := []struct { - name string - account *authtypes.BaseAccount - chainID string - msgs []sdk.Msg - setupMocks func(calculator *mocks.GasCalculator) - expectedSimulateResponse *txtypes.SimulateResponse - expectedGas uint64 - expectedError error - }{ - { - name: "Successful simulation", - account: &authtypes.BaseAccount{AccountNumber: 1, Sequence: 1}, - chainID: "test-chain", - msgs: []sdk.Msg{newMsg("sender", "contract", `{}`)}, - setupMocks: func(calculator *mocks.GasCalculator) { - calculator.WithCalculateGas(&txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, 50, nil) - }, - expectedSimulateResponse: &txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, - expectedGas: 50, - expectedError: nil, - }, - { - name: "Simulation error", - account: &authtypes.BaseAccount{AccountNumber: 2, Sequence: 2}, - chainID: "test-chain", - msgs: []sdk.Msg{}, - setupMocks: func(calculator *mocks.GasCalculator) { - calculator.WithCalculateGas(nil, 3, assert.AnError) - }, - expectedSimulateResponse: nil, - expectedGas: 3, - expectedError: assert.AnError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - calculator := mocks.GasCalculator{} - - tt.setupMocks(&calculator) - - // Call the function - result, gas, err := sqstx.SimulateMsgs( - &calculator, - encodingConfig, - tt.account, - tt.chainID, - tt.msgs, - ) - - // Assert the results - 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 TestBuildSignatures(t *testing.T) { tests := []struct { name string diff --git a/domain/mocks/calculate_gas_mock.go b/domain/mocks/calculate_gas_mock.go new file mode 100644 index 000000000..583dd3f1a --- /dev/null +++ b/domain/mocks/calculate_gas_mock.go @@ -0,0 +1,17 @@ +package mocks + +import ( + txclient "github.com/cosmos/cosmos-sdk/client/tx" + sdk "github.com/cosmos/cosmos-sdk/types" + txtypes "github.com/cosmos/cosmos-sdk/types/tx" + gogogrpc "github.com/cosmos/gogoproto/grpc" + "github.com/osmosis-labs/sqs/domain/cosmos/tx" +) + +type GetCalculateGasMock func(simulateResponse *txtypes.SimulateResponse, gasUsed uint64, err error) tx.CalculateGasFn + +var DefaultGetCalculateGasMock GetCalculateGasMock = func(simulateResponse *txtypes.SimulateResponse, gasUsed uint64, err error) tx.CalculateGasFn { + return func(clientCtx gogogrpc.ClientConn, txf txclient.Factory, msgs ...sdk.Msg) (*txtypes.SimulateResponse, uint64, error) { + return simulateResponse, gasUsed, err + } +} diff --git a/domain/mocks/gas_calculator.go b/domain/mocks/gas_calculator.go deleted file mode 100644 index ef9d40369..000000000 --- a/domain/mocks/gas_calculator.go +++ /dev/null @@ -1,25 +0,0 @@ -package mocks - -import ( - txclient "github.com/cosmos/cosmos-sdk/client/tx" - sdk "github.com/cosmos/cosmos-sdk/types" - txtypes "github.com/cosmos/cosmos-sdk/types/tx" -) - -type GasCalculator struct { - CalculateGasFunc func(txf txclient.Factory, msgs ...sdk.Msg) (*txtypes.SimulateResponse, uint64, error) -} - -func (m *GasCalculator) CalculateGas(txf txclient.Factory, msgs ...sdk.Msg) (*txtypes.SimulateResponse, uint64, error) { - if m.CalculateGasFunc != nil { - return m.CalculateGasFunc(txf, msgs...) - } - - panic("GasCalculator.CalculateGasFunc not implemented") -} - -func (m *GasCalculator) WithCalculateGas(response *txtypes.SimulateResponse, n uint64, err error) { - m.CalculateGasFunc = func(txf txclient.Factory, msgs ...sdk.Msg) (*txtypes.SimulateResponse, uint64, error) { - return response, n, err - } -} diff --git a/domain/mocks/msg_simulator_mock.go b/domain/mocks/msg_simulator_mock.go new file mode 100644 index 000000000..f6b59c307 --- /dev/null +++ b/domain/mocks/msg_simulator_mock.go @@ -0,0 +1,61 @@ +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) +} + +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") +} diff --git a/domain/mocks/tx_builder_mock.go b/domain/mocks/tx_builder_mock.go new file mode 100644 index 000000000..fa591dc70 --- /dev/null +++ b/domain/mocks/tx_builder_mock.go @@ -0,0 +1,131 @@ +package mocks + +import ( + cosmosclient "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/tx" + "github.com/cosmos/cosmos-sdk/x/auth/signing" + + signingtypes "github.com/cosmos/cosmos-sdk/types/tx/signing" +) + +type TxBuilderMock struct { + AddAuxSignerDataFn func(tx.AuxSignerData) error + + GetTxFn func() signing.Tx + + SetFeeAmountFn func(amount types.Coins) + + SetFeeGranterFn func(feeGranter types.AccAddress) + + SetFeePayerFn func(feePayer types.AccAddress) + + SetGasLimitFn func(limit uint64) + + SetMemoFn func(memo string) + + SetMsgsFn func(msgs ...interface { + ProtoMessage() + Reset() + String() string + }) error + + SetSignaturesFn func(signatures ...signingtypes.SignatureV2) error + + SetTimeoutHeightFn func(height uint64) +} + +// AddAuxSignerData implements client.TxBuilder. +func (t *TxBuilderMock) AddAuxSignerData(auxSignerData tx.AuxSignerData) error { + if t.AddAuxSignerDataFn != nil { + return t.AddAuxSignerDataFn(auxSignerData) + } + return nil +} + +// GetTx implements client.TxBuilder. +func (t *TxBuilderMock) GetTx() signing.Tx { + if t.GetTxFn != nil { + return t.GetTxFn() + } + + panic("unimplemented") +} + +// SetFeeAmount implements client.TxBuilder. +func (t *TxBuilderMock) SetFeeAmount(amount types.Coins) { + if t.SetFeeAmountFn != nil { + t.SetFeeAmountFn(amount) + } + + panic("unimplemented") +} + +// SetFeeGranter implements client.TxBuilder. +func (t *TxBuilderMock) SetFeeGranter(feeGranter types.AccAddress) { + if t.SetFeeGranterFn != nil { + t.SetFeeGranterFn(feeGranter) + } + + panic("unimplemented") +} + +// SetFeePayer implements client.TxBuilder. +func (t *TxBuilderMock) SetFeePayer(feePayer types.AccAddress) { + if t.SetFeePayerFn != nil { + t.SetFeePayerFn(feePayer) + } + + panic("unimplemented") +} + +// SetGasLimit implements client.TxBuilder. +func (t *TxBuilderMock) SetGasLimit(limit uint64) { + if t.SetGasLimitFn != nil { + t.SetGasLimitFn(limit) + } + + panic("unimplemented") +} + +// SetMemo implements client.TxBuilder. +func (t *TxBuilderMock) SetMemo(memo string) { + if t.SetMemoFn != nil { + t.SetMemoFn(memo) + } + + panic("unimplemented") +} + +// SetMsgs implements client.TxBuilder. +func (t *TxBuilderMock) SetMsgs(msgs ...interface { + ProtoMessage() + Reset() + String() string +}) error { + if t.SetMsgsFn != nil { + return t.SetMsgsFn(msgs...) + } + + panic("unimplemented") +} + +// SetSignatures implements client.TxBuilder. +func (t *TxBuilderMock) SetSignatures(signatures ...signingtypes.SignatureV2) error { + if t.SetSignaturesFn != nil { + return t.SetSignaturesFn(signatures...) + } + + panic("unimplemented") +} + +// SetTimeoutHeight implements client.TxBuilder. +func (t *TxBuilderMock) SetTimeoutHeight(height uint64) { + if t.SetTimeoutHeightFn != nil { + t.SetTimeoutHeightFn(height) + } + + panic("unimplemented") +} + +var _ cosmosclient.TxBuilder = &TxBuilderMock{} diff --git a/domain/mocks/tx_config_mock.go b/domain/mocks/tx_config_mock.go new file mode 100644 index 000000000..fda5f8985 --- /dev/null +++ b/domain/mocks/tx_config_mock.go @@ -0,0 +1,68 @@ +package mocks + +import ( + "cosmossdk.io/x/tx/signing" + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/types" + signingtypes "github.com/cosmos/cosmos-sdk/types/tx/signing" +) + +type TxConfigMock struct { + TxEncoderFn func() types.TxEncoder +} + +// MarshalSignatureJSON implements client.TxConfig. +func (t *TxConfigMock) MarshalSignatureJSON([]signingtypes.SignatureV2) ([]byte, error) { + panic("unimplemented") +} + +// NewTxBuilder implements client.TxConfig. +func (t *TxConfigMock) NewTxBuilder() client.TxBuilder { + panic("unimplemented") +} + +// SignModeHandler implements client.TxConfig. +func (t *TxConfigMock) SignModeHandler() *signing.HandlerMap { + panic("unimplemented") +} + +// SigningContext implements client.TxConfig. +func (t *TxConfigMock) SigningContext() *signing.Context { + panic("unimplemented") +} + +// TxDecoder implements client.TxConfig. +func (t *TxConfigMock) TxDecoder() types.TxDecoder { + panic("unimplemented") +} + +// TxEncoder implements client.TxConfig. +func (t *TxConfigMock) TxEncoder() types.TxEncoder { + if t.TxEncoderFn != nil { + return t.TxEncoderFn() + } + + panic("unimplemented") +} + +// TxJSONDecoder implements client.TxConfig. +func (t *TxConfigMock) TxJSONDecoder() types.TxDecoder { + panic("unimplemented") +} + +// TxJSONEncoder implements client.TxConfig. +func (t *TxConfigMock) TxJSONEncoder() types.TxEncoder { + panic("unimplemented") +} + +// UnmarshalSignatureJSON implements client.TxConfig. +func (t *TxConfigMock) UnmarshalSignatureJSON([]byte) ([]signingtypes.SignatureV2, error) { + panic("unimplemented") +} + +// WrapTxBuilder implements client.TxConfig. +func (t *TxConfigMock) WrapTxBuilder(types.Tx) (client.TxBuilder, error) { + panic("unimplemented") +} + +var _ client.TxConfig = &TxConfigMock{} diff --git a/domain/mocks/tx_mock.go b/domain/mocks/tx_mock.go new file mode 100644 index 000000000..02f5f1094 --- /dev/null +++ b/domain/mocks/tx_mock.go @@ -0,0 +1,135 @@ +package mocks + +import ( + "github.com/cosmos/cosmos-sdk/crypto/types" + sdk "github.com/cosmos/cosmos-sdk/types" + signingtypes "github.com/cosmos/cosmos-sdk/types/tx/signing" + "github.com/cosmos/cosmos-sdk/x/auth/signing" + "github.com/cosmos/gogoproto/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +var _ signing.Tx = &TxMock{} + +type TxMock struct { + FeeGranterFn func() []byte + FeePayerFn func() []byte + GetFeeFn func() sdk.Coins + GetGasFn func() uint64 + GetMemoFn func() string + GetMsgsFn func() []proto.Message + GetMsgsV2Fn func() ([]protoreflect.ProtoMessage, error) + GetPubKeysFn func() ([]types.PubKey, error) + GetSignaturesV2Fn func() ([]signingtypes.SignatureV2, error) + GetSignersFn func() ([][]byte, error) + GetTimeoutHeightFn func() uint64 + ValidateBasicFn func() error +} + +// FeeGranter implements signing.Tx. +func (t *TxMock) FeeGranter() []byte { + if t.FeeGranterFn != nil { + return t.FeeGranterFn() + } + + panic("unimplemented") +} + +// FeePayer implements signing.Tx. +func (t *TxMock) FeePayer() []byte { + if t.FeePayerFn != nil { + return t.FeePayerFn() + } + + panic("unimplemented") +} + +// GetFee implements signing.Tx. +func (t *TxMock) GetFee() sdk.Coins { + if t.GetFeeFn != nil { + return t.GetFeeFn() + } + + panic("unimplemented") +} + +// GetGas implements signing.Tx. +func (t *TxMock) GetGas() uint64 { + if t.GetGasFn != nil { + return t.GetGasFn() + } + + panic("unimplemented") +} + +// GetMemo implements signing.Tx. +func (t *TxMock) GetMemo() string { + if t.GetMemoFn != nil { + return t.GetMemoFn() + } + + panic("unimplemented") +} + +// GetMsgs implements signing.Tx. +func (t *TxMock) GetMsgs() []proto.Message { + if t.GetMsgsFn != nil { + return t.GetMsgsFn() + } + + panic("unimplemented") +} + +// GetMsgsV2 implements signing.Tx. +func (t *TxMock) GetMsgsV2() ([]protoreflect.ProtoMessage, error) { + if t.GetMsgsV2Fn != nil { + return t.GetMsgsV2Fn() + } + + panic("unimplemented") +} + +// GetPubKeys implements signing.Tx. +func (t *TxMock) GetPubKeys() ([]types.PubKey, error) { + if t.GetPubKeysFn != nil { + return t.GetPubKeysFn() + } + + panic("unimplemented") +} + +// GetSignaturesV2 implements signing.Tx. +func (t *TxMock) GetSignaturesV2() ([]signingtypes.SignatureV2, error) { + if t.GetSignaturesV2Fn != nil { + return t.GetSignaturesV2Fn() + } + + panic("unimplemented") +} + +// GetSigners implements signing.Tx. +func (t *TxMock) GetSigners() ([][]byte, error) { + if t.GetSignersFn != nil { + return t.GetSignersFn() + } + + panic("unimplemented") +} + +// GetTimeoutHeight implements signing.Tx. +func (t *TxMock) GetTimeoutHeight() uint64 { + if t.GetTimeoutHeightFn != nil { + return t.GetTimeoutHeightFn() + } + + panic("unimplemented") +} + +// ValidateBasic implements signing.Tx. +func (t *TxMock) ValidateBasic() error { + if t.ValidateBasicFn != nil { + return t.ValidateBasicFn() + } + + panic("unimplemented") +} diff --git a/ingest/usecase/plugins/orderbook/claimbot/config.go b/ingest/usecase/plugins/orderbook/claimbot/config.go index cc25934bd..3046cbc0a 100644 --- a/ingest/usecase/plugins/orderbook/claimbot/config.go +++ b/ingest/usecase/plugins/orderbook/claimbot/config.go @@ -20,7 +20,7 @@ type Config struct { OrderbookUsecase mvc.OrderBookUsecase AccountQueryClient authtypes.QueryClient TxfeesClient txfeestypes.QueryClient - GasCalculator sqstx.GasCalculator + MsgSimulator sqstx.MsgSimulator TxServiceClient txtypes.ServiceClient ChainID string Logger log.Logger @@ -46,7 +46,7 @@ func NewConfig( OrderbookUsecase: orderbookusecase, AccountQueryClient: authtypes.NewQueryClient(grpcClient), TxfeesClient: txfeestypes.NewQueryClient(grpcClient), - GasCalculator: sqstx.NewGasCalculator(grpcClient), + MsgSimulator: sqstx.NewGasCalculator(grpcClient, sqstx.CalculateGas), TxServiceClient: txtypes.NewServiceClient(grpcClient), Logger: logger.Named("claimbot"), ChainID: chainID, diff --git a/ingest/usecase/plugins/orderbook/claimbot/export_test.go b/ingest/usecase/plugins/orderbook/claimbot/export_test.go index ad9ee4597..3a117ac59 100644 --- a/ingest/usecase/plugins/orderbook/claimbot/export_test.go +++ b/ingest/usecase/plugins/orderbook/claimbot/export_test.go @@ -9,6 +9,7 @@ import ( "github.com/osmosis-labs/sqs/domain/mvc" orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" + "github.com/osmosis-labs/osmosis/v26/app/params" txfeestypes "github.com/osmosis-labs/osmosis/v26/x/txfees/types" "github.com/osmosis-labs/osmosis/osmomath" @@ -21,6 +22,12 @@ import ( // ProcessedOrderbook is order alias data structure for testing purposes. type ProcessedOrderbook = processedOrderbook +var ( + EncodingConfig = encodingConfig + + DefaultEncodingConfigFn = defaultEncodingConfigFn +) + // ProcessOrderbooksAndGetClaimableOrders is test wrapper for processOrderbooksAndGetClaimableOrders. // This function is exported for testing purposes. func ProcessOrderbooksAndGetClaimableOrders( @@ -34,18 +41,19 @@ func ProcessOrderbooksAndGetClaimableOrders( // SendBatchClaimTx a test wrapper for sendBatchClaimTx. // This function is used only for testing purposes. -func SendBatchClaimTx( +func SendBatchClaimTxInternal( ctx context.Context, keyring keyring.Keyring, txfeesClient txfeestypes.QueryClient, - gasCalculator sqstx.GasCalculator, + msgSimulator sqstx.MsgSimulator, txServiceClient txtypes.ServiceClient, chainID string, account *authtypes.BaseAccount, contractAddress string, claims orderbookdomain.Orders, + getEncodingConfig func() params.EncodingConfig, ) (*sdk.TxResponse, error) { - return sendBatchClaimTx(ctx, keyring, txfeesClient, gasCalculator, txServiceClient, chainID, account, contractAddress, claims) + return sendBatchClaimTxInternal(ctx, keyring, txfeesClient, msgSimulator, txServiceClient, chainID, account, contractAddress, claims, getEncodingConfig) } // PrepareBatchClaimMsg is a test wrapper for prepareBatchClaimMsg. diff --git a/ingest/usecase/plugins/orderbook/claimbot/plugin.go b/ingest/usecase/plugins/orderbook/claimbot/plugin.go index c9042c220..625cbad99 100644 --- a/ingest/usecase/plugins/orderbook/claimbot/plugin.go +++ b/ingest/usecase/plugins/orderbook/claimbot/plugin.go @@ -173,7 +173,7 @@ func (o *claimbot) processOrderbookOrders(ctx context.Context, account *authtype ctx, o.config.Keyring, o.config.TxfeesClient, - o.config.GasCalculator, + o.config.MsgSimulator, o.config.TxServiceClient, o.config.ChainID, account, diff --git a/ingest/usecase/plugins/orderbook/claimbot/tx.go b/ingest/usecase/plugins/orderbook/claimbot/tx.go index 7456ebde0..2ab6eb494 100644 --- a/ingest/usecase/plugins/orderbook/claimbot/tx.go +++ b/ingest/usecase/plugins/orderbook/claimbot/tx.go @@ -9,17 +9,22 @@ import ( "github.com/osmosis-labs/sqs/domain/keyring" orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" - "github.com/osmosis-labs/osmosis/v26/app" - txfeestypes "github.com/osmosis-labs/osmosis/v26/x/txfees/types" - wasmtypes "github.com/CosmWasm/wasmd/x/wasm/types" 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" + "github.com/osmosis-labs/osmosis/v26/app/params" + txfeestypes "github.com/osmosis-labs/osmosis/v26/x/txfees/types" ) var ( - encodingConfig = app.MakeEncodingConfig() + // Note: we monkey patch the encoding config in tests + encodingConfig params.EncodingConfig = app.MakeEncodingConfig() + + defaultEncodingConfigFn = func() params.EncodingConfig { + return encodingConfig + } ) // sendBatchClaimTx prepares and sends a batch claim transaction to the blockchain. @@ -28,13 +33,32 @@ func sendBatchClaimTx( ctx context.Context, keyring keyring.Keyring, txfeesClient txfeestypes.QueryClient, - gasCalculator sqstx.GasCalculator, + msgSimulator sqstx.MsgSimulator, txServiceClient txtypes.ServiceClient, chainID string, account *authtypes.BaseAccount, contractAddress string, claims orderbookdomain.Orders, ) (*sdk.TxResponse, error) { + return sendBatchClaimTxInternal(ctx, keyring, txfeesClient, msgSimulator, txServiceClient, chainID, account, contractAddress, claims, defaultEncodingConfigFn) +} + +// sendBatchClaimTxInternal is a helper function that prepares and sends a batch claim transaction to the blockchain. +// It takes an encoding config function as a parameter to allow for customization of the encoding config in tests. +func sendBatchClaimTxInternal( + ctx context.Context, + keyring keyring.Keyring, + txfeesClient txfeestypes.QueryClient, + msgSimulator sqstx.MsgSimulator, + txServiceClient txtypes.ServiceClient, + chainID string, + account *authtypes.BaseAccount, + contractAddress string, + claims orderbookdomain.Orders, + getEncodingConfig func() params.EncodingConfig, +) (*sdk.TxResponse, error) { + encodingConfig := getEncodingConfig() + address := keyring.GetAddress().String() msgBytes, err := prepareBatchClaimMsg(claims) @@ -44,7 +68,7 @@ func sendBatchClaimTx( msg := buildExecuteContractMsg(address, contractAddress, msgBytes) - tx, err := sqstx.BuildTx(ctx, keyring, txfeesClient, gasCalculator, encodingConfig, account, chainID, msg) + tx, err := msgSimulator.BuildTx(ctx, keyring, txfeesClient, encodingConfig, account, chainID, msg) if err != nil { return nil, fmt.Errorf("failed to build transaction: %w", err) } diff --git a/ingest/usecase/plugins/orderbook/claimbot/tx_test.go b/ingest/usecase/plugins/orderbook/claimbot/tx_test.go index 79bc6c652..75031f928 100644 --- a/ingest/usecase/plugins/orderbook/claimbot/tx_test.go +++ b/ingest/usecase/plugins/orderbook/claimbot/tx_test.go @@ -4,26 +4,36 @@ import ( "context" "testing" - "github.com/osmosis-labs/sqs/domain/mocks" - orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" - "github.com/osmosis-labs/sqs/ingest/usecase/plugins/orderbook/claimbot" - + cosmosclient "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/types" sdk "github.com/cosmos/cosmos-sdk/types" txtypes "github.com/cosmos/cosmos-sdk/types/tx" + "github.com/cosmos/cosmos-sdk/x/auth/signing" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" - + "github.com/osmosis-labs/osmosis/v26/app" + "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" + "github.com/osmosis-labs/sqs/domain/mocks" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" + "github.com/osmosis-labs/sqs/ingest/usecase/plugins/orderbook/claimbot" "github.com/stretchr/testify/assert" "google.golang.org/grpc" ) func TestSendBatchClaimTx(t *testing.T) { + const mockedTxBytes = "mocked-tx-bytes" + tests := []struct { - name string - chainID string - contractAddress string - claims orderbookdomain.Orders - setupMocks func(*mocks.Keyring, *authtypes.BaseAccount, *mocks.TxFeesQueryClient, *mocks.GasCalculator, *mocks.TxServiceClient) - setSendTxFunc func() []byte + name string + chainID string + contractAddress string + claims orderbookdomain.Orders + setupMocks func(*mocks.Keyring, *authtypes.BaseAccount, *mocks.TxFeesQueryClient, *mocks.MsgSimulatorMock, *mocks.TxServiceClient) + setSendTxFunc func() []byte + + getEncodingConfigFn func() params.EncodingConfig + expectedResponse *sdk.TxResponse expectedError bool }{ @@ -33,17 +43,29 @@ func TestSendBatchClaimTx(t *testing.T) { claims: orderbookdomain.Orders{ {TickId: 13, OrderId: 99}, }, - setupMocks: func(keyringMock *mocks.Keyring, account *authtypes.BaseAccount, txfeesClient *mocks.TxFeesQueryClient, gasCalculator *mocks.GasCalculator, txServiceClient *mocks.TxServiceClient) { + setupMocks: func(keyringMock *mocks.Keyring, account *authtypes.BaseAccount, txfeesClient *mocks.TxFeesQueryClient, msgSimulator *mocks.MsgSimulatorMock, txServiceClient *mocks.TxServiceClient) { keyringMock.WithGetAddress("osmo0address") keyringMock.WithGetKey("6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159") account = &authtypes.BaseAccount{ AccountNumber: 3, Sequence: 31, } - gasCalculator.WithCalculateGas(nil, 0, assert.AnError) // Fail BuildTx + // Fail BuildTx + msgSimulator.BuildTxFn = func( + ctx context.Context, + keyring keyring.Keyring, + txfeesClient txfeestypes.QueryClient, + encodingConfig params.EncodingConfig, + account *authtypes.BaseAccount, + chainID string, + msg ...sdk.Msg, + ) (cosmosclient.TxBuilder, error) { + return nil, assert.AnError + } }, - expectedResponse: &sdk.TxResponse{}, - expectedError: true, + getEncodingConfigFn: claimbot.DefaultEncodingConfigFn, + expectedResponse: &sdk.TxResponse{}, + expectedError: true, }, { name: "SendTx returns error", @@ -51,10 +73,24 @@ func TestSendBatchClaimTx(t *testing.T) { claims: orderbookdomain.Orders{ {TickId: 13, OrderId: 99}, }, - setupMocks: func(keyringMock *mocks.Keyring, account *authtypes.BaseAccount, txfeesClient *mocks.TxFeesQueryClient, gasCalculator *mocks.GasCalculator, txServiceClient *mocks.TxServiceClient) { + setupMocks: func(keyringMock *mocks.Keyring, account *authtypes.BaseAccount, txfeesClient *mocks.TxFeesQueryClient, msgSimulator *mocks.MsgSimulatorMock, txServiceClient *mocks.TxServiceClient) { keyringMock.WithGetAddress("osmo5address") keyringMock.WithGetKey("6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159") - gasCalculator.WithCalculateGas(nil, 51, nil) + msgSimulator.BuildTxFn = func( + ctx context.Context, + keyring keyring.Keyring, + txfeesClient txfeestypes.QueryClient, + encodingConfig params.EncodingConfig, + account *authtypes.BaseAccount, + chainID string, + msg ...sdk.Msg, + ) (cosmosclient.TxBuilder, error) { + return &mocks.TxBuilderMock{ + GetTxFn: func() signing.Tx { + return &mocks.TxMock{} + }, + }, nil + } txfeesClient.WithBaseDenom("uosmo", nil) txfeesClient.WithGetEipBaseFee("0.2", nil) account = &authtypes.BaseAccount{ @@ -63,8 +99,9 @@ func TestSendBatchClaimTx(t *testing.T) { } txServiceClient.WithBroadcastTx(nil, assert.AnError) // SendTx returns error }, - expectedResponse: &sdk.TxResponse{}, - expectedError: true, + getEncodingConfigFn: claimbot.DefaultEncodingConfigFn, + expectedResponse: &sdk.TxResponse{}, + expectedError: true, }, { name: "Successful transaction", @@ -74,10 +111,24 @@ func TestSendBatchClaimTx(t *testing.T) { {TickId: 1, OrderId: 100}, {TickId: 2, OrderId: 200}, }, - setupMocks: func(keyringMock *mocks.Keyring, account *authtypes.BaseAccount, txfeesClient *mocks.TxFeesQueryClient, gasCalculator *mocks.GasCalculator, txServiceClient *mocks.TxServiceClient) { + setupMocks: func(keyringMock *mocks.Keyring, account *authtypes.BaseAccount, txfeesClient *mocks.TxFeesQueryClient, msgSimulator *mocks.MsgSimulatorMock, txServiceClient *mocks.TxServiceClient) { keyringMock.WithGetAddress("osmo1address") keyringMock.WithGetKey("6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159") - gasCalculator.WithCalculateGas(nil, 51, nil) + msgSimulator.BuildTxFn = func( + ctx context.Context, + keyring keyring.Keyring, + txfeesClient txfeestypes.QueryClient, + encodingConfig params.EncodingConfig, + account *authtypes.BaseAccount, + chainID string, + msg ...sdk.Msg, + ) (cosmosclient.TxBuilder, error) { + return &mocks.TxBuilderMock{ + GetTxFn: func() signing.Tx { + return &mocks.TxMock{} + }, + }, nil + } txfeesClient.WithBaseDenom("uosmo", nil) txfeesClient.WithGetEipBaseFee("0.15", nil) account = &authtypes.BaseAccount{ @@ -93,8 +144,21 @@ func TestSendBatchClaimTx(t *testing.T) { }, nil } }, + + getEncodingConfigFn: func() params.EncodingConfig { + encoding := app.MakeEncodingConfig() + encoding.TxConfig = &mocks.TxConfigMock{ + TxEncoderFn: func() types.TxEncoder { + return func(tx types.Tx) ([]byte, error) { + return []byte(mockedTxBytes), nil + } + }, + } + return encoding + }, + expectedResponse: &sdk.TxResponse{ - Data: "\n\x90\x01\n\x8d\x01\n$/cosmwasm.wasm.v1.MsgExecuteContract\x12e\n\x1fosmo1daek6me3v9jxgun9wdes7m4n5q\x12\x14osmo1contractaddress\x1a,{\"batch_claim\":{\"orders\":[[1,100],[2,200]]}}\x12`\nN\nF\n\x1f/cosmos.crypto.secp256k1.PubKey\x12#\n!\x03\xef]m\xf2\x8a\bx\x1f\x9a%v]E\x9e\x96\xa8\x9dc6a\x1d\x1f\x8a\xb4\xd3/q,֍\xd3\xd0\x12\x04\n\x02\b\x01\x12\x0e\n\n\n\x05uosmo\x12\x018\x103\x1a@\x1dI\xb5/D\xd0L\v2\xacg\x91\xb3;b+\xdb\xf6\xe0\x1c\x92\xee\xb8d\xc4&%<ڵ\x81\xd6u\xeb-\xf0ੌ\xf5\xa8);\x19\xfc%@\r\xfb2\x05AI\x13\xf3)=\n\xcf~\xb0\"\xf0\xb1", + Data: string(mockedTxBytes), }, expectedError: false, }, @@ -102,16 +166,18 @@ func TestSendBatchClaimTx(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() keyring := mocks.Keyring{} account := authtypes.BaseAccount{} txFeesClient := mocks.TxFeesQueryClient{} - gasCalculator := mocks.GasCalculator{} txServiceClient := mocks.TxServiceClient{} - tt.setupMocks(&keyring, &account, &txFeesClient, &gasCalculator, &txServiceClient) + txSimulatorMock := mocks.MsgSimulatorMock{} + + tt.setupMocks(&keyring, &account, &txFeesClient, &txSimulatorMock, &txServiceClient) - response, err := claimbot.SendBatchClaimTx(ctx, &keyring, &txFeesClient, &gasCalculator, &txServiceClient, tt.chainID, &account, tt.contractAddress, tt.claims) + response, err := claimbot.SendBatchClaimTxInternal(ctx, &keyring, &txFeesClient, &txSimulatorMock, &txServiceClient, tt.chainID, &account, tt.contractAddress, tt.claims, tt.getEncodingConfigFn) if tt.expectedError { assert.Error(t, err) } else { From f2931d81734e08ece95fc5e5641ce4ec8c08c302 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 4 Nov 2024 09:36:05 -0800 Subject: [PATCH 05/12] 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 --- app/sidecar_query_server.go | 17 +- docs/docs.go | 20 ++- docs/swagger.json | 20 ++- docs/swagger.yaml | 16 +- domain/cosmos/tx/msg_simulator.go | 47 ++++-- domain/cosmos/tx/msg_simulator_test.go | 154 ++++++++++++++---- domain/mocks/msg_simulator_mock.go | 21 +++ 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 | 14 +- 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, 926 insertions(+), 94 deletions(-) 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 index 0778fffd0..9a88df574 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/cosmos/tx/msg_simulator_test.go b/domain/cosmos/tx/msg_simulator_test.go index 20849e3ab..c18ac22af 100644 --- a/domain/cosmos/tx/msg_simulator_test.go +++ b/domain/cosmos/tx/msg_simulator_test.go @@ -7,11 +7,30 @@ import ( 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 @@ -25,39 +44,35 @@ func TestSimulateMsgs(t *testing.T) { }{ { name: "Successful simulation", - account: &authtypes.BaseAccount{AccountNumber: 1, Sequence: 1}, - chainID: "test-chain", - msgs: []sdk.Msg{newMsg("sender", "contract", `{}`)}, + account: testAccount, + chainID: testChainID, + msgs: []sdk.Msg{testMsg}, setupMocks: func(calculator mocks.GetCalculateGasMock) tx.CalculateGasFn { - return calculator(&txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, 50, nil) + return calculator(&txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, testGasUsed, nil) }, expectedSimulateResponse: &txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, - expectedGas: 50, + expectedGas: testGasUsed, expectedError: nil, }, { name: "Simulation error", account: &authtypes.BaseAccount{AccountNumber: 2, Sequence: 2}, - chainID: "test-chain", + chainID: testChainID, msgs: []sdk.Msg{}, setupMocks: func(calculator mocks.GetCalculateGasMock) tx.CalculateGasFn { - return calculator(&txtypes.SimulateResponse{}, 3, assert.AnError) + return calculator(&txtypes.SimulateResponse{}, testGasUsed, assert.AnError) }, expectedSimulateResponse: nil, - expectedGas: 3, + expectedGas: testGasUsed, expectedError: assert.AnError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Setup the mock calculateGasFnMock := tt.setupMocks(mocks.DefaultGetCalculateGasMock) - - // Create the gas calculator gasCalculator := tx.NewGasCalculator(nil, calculateGasFnMock) - // Call the function result, gas, err := gasCalculator.SimulateMsgs( encodingConfig.TxConfig, tt.account, @@ -65,7 +80,6 @@ func TestSimulateMsgs(t *testing.T) { tt.msgs, ) - // Assert the results assert.Equal(t, tt.expectedSimulateResponse, result) assert.Equal(t, tt.expectedGas, gas) if tt.expectedError != nil { @@ -91,27 +105,23 @@ func TestBuildTx(t *testing.T) { { name: "Valid transaction", setupMocks: func(calculator mocks.GetCalculateGasMock, txFeesClient *mocks.TxFeesQueryClient, keyring *mocks.Keyring) tx.CalculateGasFn { - keyring.WithGetKey("6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159") - txFeesClient.WithBaseDenom("eth", nil) - txFeesClient.WithGetEipBaseFee("0.1", nil) + keyring.WithGetKey(testKey) + txFeesClient.WithBaseDenom(testDenom, nil) + txFeesClient.WithGetEipBaseFee(testBaseFee, nil) - return calculator(&txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, 50, nil) - }, - account: &authtypes.BaseAccount{ - Sequence: 13, - AccountNumber: 1, + return calculator(&txtypes.SimulateResponse{GasInfo: &sdk.GasInfo{GasUsed: 100000}}, testGasUsed, nil) }, - chainID: "test-chain", - msgs: []sdk.Msg{newMsg("sender", "contract", `{"payload": "hello contract"}`)}, - expectedJSON: []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=="]}`), + 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("6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159") - - return calculator(&txtypes.SimulateResponse{}, 50, assert.AnError) + keyring.WithGetKey(testKey) + return calculator(&txtypes.SimulateResponse{}, testGasUsed, assert.AnError) }, account: &authtypes.BaseAccount{ Sequence: 8, @@ -126,10 +136,7 @@ func TestBuildTx(t *testing.T) { txFeesClient := mocks.TxFeesQueryClient{} keyring := mocks.Keyring{} - // Setup the mock calculateGasFnMock := tc.setupMocks(mocks.DefaultGetCalculateGasMock, &txFeesClient, &keyring) - - // Create the gas calculator msgSimulator := tx.NewGasCalculator(nil, calculateGasFnMock) txBuilder, err := msgSimulator.BuildTx( @@ -151,10 +158,93 @@ func TestBuildTx(t *testing.T) { txJSONBytes, err := encodingConfig.TxConfig.TxJSONEncoder()(txBuilder.GetTx()) assert.NoError(t, err) - - // Add more specific assertions here based on the expected output 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 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/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 3ce73fcf5..bc1affcdb 100644 --- a/domain/passthrough/passthrough_grpc_client.go +++ b/domain/passthrough/passthrough_grpc_client.go @@ -11,9 +11,8 @@ import ( math "github.com/osmosis-labs/osmosis/osmomath" concentratedLiquidity "github.com/osmosis-labs/osmosis/v26/x/concentrated-liquidity/client/queryproto" lockup "github.com/osmosis-labs/osmosis/v26/x/lockup/types" - "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + polarisgrpc "github.com/osmosis-labs/sqs/delivery/grpc" "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 +39,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 +56,7 @@ type passthroughGRPCClient struct { concentratedLiquidityQueryClient concentratedLiquidity.QueryClient distributionClient distribution.QueryClient - chainGRPCClient *grpc.ClientConn + chainGRPCClient grpc.ClientConnInterface } const ( @@ -69,10 +68,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 +195,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 From 28f59d89cb6fa977b0443dc15df8900add72e6d0 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 4 Nov 2024 17:39:13 +0000 Subject: [PATCH 06/12] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b99a9864b..d39794a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ ## Unreleased +- #547 - Add /quote simulation for "out given in" single routes. - #526 - Refactor gas estimation APIs - #524 - Claimbot From 0e45457d77e3e4477b67ca2eb33dba6e3f7ece72 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Wed, 6 Nov 2024 18:28:45 +0200 Subject: [PATCH 07/12] BE-632 | Include limit order balances in passthrough/portfolio-assets Includes limit order balances in total balance for passthrough/portfolio-assets --- app/sidecar_query_server.go | 12 +- domain/mvc/orderbook.go | 1 + domain/orderbook/order.go | 94 +++++++++++---- domain/orderbook/order_test.go | 109 +++++++++++++++--- orderbook/usecase/orderbook_usecase.go | 11 +- passthrough/usecase/export_test.go | 1 + passthrough/usecase/passthrough_usecase.go | 50 +++++--- .../usecase/passthrough_usecase_test.go | 29 ++++- 8 files changed, 236 insertions(+), 71 deletions(-) diff --git a/app/sidecar_query_server.go b/app/sidecar_query_server.go index b86287c1e..69b71b86f 100644 --- a/app/sidecar_query_server.go +++ b/app/sidecar_query_server.go @@ -185,8 +185,13 @@ func NewSideCarQueryServer(appCodec codec.Codec, config domain.Config, logger lo return nil, err } + wasmQueryClient := wasmtypes.NewQueryClient(passthroughGRPCClient.GetChainGRPCClient()) + orderBookAPIClient := orderbookgrpcclientdomain.New(wasmQueryClient) + orderBookRepository := orderbookrepository.New() + orderBookUseCase := orderbookusecase.New(orderBookRepository, orderBookAPIClient, poolsUseCase, tokensUseCase, logger) + // Initialize passthrough query use case - passthroughUseCase := passthroughUseCase.NewPassThroughUsecase(passthroughGRPCClient, poolsUseCase, tokensUseCase, liquidityPricer, defaultQuoteDenom, logger) + passthroughUseCase := passthroughUseCase.NewPassThroughUsecase(passthroughGRPCClient, poolsUseCase, tokensUseCase, orderBookUseCase, liquidityPricer, defaultQuoteDenom, logger) if err != nil { return nil, err } @@ -203,11 +208,6 @@ func NewSideCarQueryServer(appCodec codec.Codec, config domain.Config, logger lo tokensUseCase.RegisterPricingStrategy(domain.ChainPricingSourceType, chainPricingSource) tokensUseCase.RegisterPricingStrategy(domain.CoinGeckoPricingSourceType, coingeckoPricingSource) - wasmQueryClient := wasmtypes.NewQueryClient(passthroughGRPCClient.GetChainGRPCClient()) - orderBookAPIClient := orderbookgrpcclientdomain.New(wasmQueryClient) - orderBookRepository := orderbookrepository.New() - orderBookUseCase := orderbookusecase.New(orderBookRepository, orderBookAPIClient, poolsUseCase, tokensUseCase, logger) - // HTTP handlers poolsHttpDelivery.NewPoolsHandler(e, poolsUseCase) passthroughHttpDelivery.NewPassthroughHandler(e, passthroughUseCase, orderBookUseCase, logger) diff --git a/domain/mvc/orderbook.go b/domain/mvc/orderbook.go index c1cb18206..c6daf7c21 100644 --- a/domain/mvc/orderbook.go +++ b/domain/mvc/orderbook.go @@ -17,6 +17,7 @@ type OrderBookUsecase interface { GetAllTicks(poolID uint64) (map[int64]orderbookdomain.OrderbookTick, bool) // GetOrder returns all active orderbook orders for a given address. + // If there was an error fetching the orders, the second return value will be true indicating best effort. GetActiveOrders(ctx context.Context, address string) ([]orderbookdomain.LimitOrder, bool, error) // GetActiveOrdersStream returns a channel for streaming limit orderbook orders for a given address. diff --git a/domain/orderbook/order.go b/domain/orderbook/order.go index 66d542522..61bd23a5d 100644 --- a/domain/orderbook/order.go +++ b/domain/orderbook/order.go @@ -6,6 +6,30 @@ import ( "github.com/osmosis-labs/osmosis/osmomath" ) +// NewDirection returns a new order direction. +func NewDirection(direction string) OrderDirection { + return OrderDirection(direction) +} + +// Direction represents the direction of an order. +type OrderDirection string + +// Is returns true if the order direction is equal to the given direction. +func (d OrderDirection) Is(direction OrderDirection) bool { + return d == direction +} + +// String returns the string representation of the order direction. +func (d OrderDirection) String() string { + return string(d) +} + +// Order direction types. +const ( + DirectionBid OrderDirection = "bid" + DirectionAsk OrderDirection = "ask" +) + // OrderStatus represents the status of an order. type OrderStatus string @@ -20,13 +44,20 @@ const ( // Order represents an order in the orderbook returned by the orderbook contract. type Order struct { - TickId int64 `json:"tick_id"` - OrderId int64 `json:"order_id"` + // The price for which to place the order. + // Can be calculated following the tick calculations: + // https://github.com/osmosis-labs/orderbook/edit/main/contracts/sumtree-orderbook/README.md#tick-calculations + TickId int64 `json:"tick_id"` + // The ID of the order + OrderId int64 `json:"order_id"` + // The direction for which to place the order, should be either an ask order or a bid order OrderDirection string `json:"order_direction"` Owner string `json:"owner"` Quantity string `json:"quantity"` Etas string `json:"etas"` - ClaimBounty string `json:"claim_bounty"` + // An optional percentage bounty to claim the order, capped at 1% + ClaimBounty string `json:"claim_bounty"` + // Immutable quantity of the order when placed PlacedQuantity string `json:"placed_quantity"` PlacedAt string `json:"placed_at"` } @@ -67,10 +98,10 @@ func (o Orders) TickID() []int64 { // OrderByDirection filters orders by given direction and returns resulting slice. // Original slice is not mutated. -func (o Orders) OrderByDirection(direction string) Orders { +func (o Orders) OrderByDirection(direction OrderDirection) Orders { var result Orders for _, v := range o { - if v.OrderDirection == direction { + if NewDirection(v.OrderDirection).Is(direction) { result = append(result, v) } } @@ -85,25 +116,25 @@ type Asset struct { // LimitOrder represents a limit order in the orderbook. type LimitOrder struct { - TickId int64 `json:"tick_id"` - OrderId int64 `json:"order_id"` - OrderDirection string `json:"order_direction"` - Owner string `json:"owner"` - Quantity osmomath.Dec `json:"quantity"` - Etas string `json:"etas"` - ClaimBounty string `json:"claim_bounty"` - PlacedQuantity osmomath.Dec `json:"placed_quantity"` - PlacedAt int64 `json:"placed_at"` - Price osmomath.Dec `json:"price"` - PercentClaimed osmomath.Dec `json:"percentClaimed"` - TotalFilled osmomath.Dec `json:"totalFilled"` - PercentFilled osmomath.Dec `json:"percentFilled"` - OrderbookAddress string `json:"orderbookAddress"` - Status OrderStatus `json:"status"` - Output osmomath.Dec `json:"output"` - QuoteAsset Asset `json:"quote_asset"` - BaseAsset Asset `json:"base_asset"` - PlacedTx *string `json:"placed_tx,omitempty"` + TickId int64 `json:"tick_id"` + OrderId int64 `json:"order_id"` + OrderDirection OrderDirection `json:"order_direction"` + Owner string `json:"owner"` + Quantity osmomath.Dec `json:"quantity"` + Etas string `json:"etas"` + ClaimBounty string `json:"claim_bounty"` + PlacedQuantity osmomath.Dec `json:"placed_quantity"` + PlacedAt int64 `json:"placed_at"` + Price osmomath.Dec `json:"price"` + PercentClaimed osmomath.Dec `json:"percentClaimed"` + TotalFilled osmomath.Dec `json:"totalFilled"` + PercentFilled osmomath.Dec `json:"percentFilled"` + OrderbookAddress string `json:"orderbookAddress"` + Status OrderStatus `json:"status"` + Output osmomath.Dec `json:"output"` + QuoteAsset Asset `json:"quote_asset"` + BaseAsset Asset `json:"base_asset"` + PlacedTx *string `json:"placed_tx,omitempty"` } // IsClaimable reports whether the limit order is filled above the given @@ -112,6 +143,21 @@ func (o LimitOrder) IsClaimable(threshold osmomath.Dec) bool { return o.PercentFilled.GT(threshold) && o.PercentFilled.LTE(osmomath.OneDec()) } +// ClaimableAmountOfOSMO calculates the claimable amount of base asset +func (o LimitOrder) ClaimableAmount() osmomath.Dec { + return o.TotalFilled.Sub(o.TotalFilled.Mul(o.PercentClaimed)) +} + +// GetQuoteFillableAmount calculates the amount of quote asset left to be filled +func (o LimitOrder) QuoteAssetFillableAmount() osmomath.Dec { + return o.PlacedQuantity.Sub(o.Output.Mul(o.PercentFilled)) +} + +// GetBaseAssetFillableAmount calculates the amount of base asset left to be filled +func (o LimitOrder) BaseAssetFillableAmount() osmomath.Dec { + return o.Quantity.Sub(o.TotalFilled) +} + // OrderbookResult represents orderbook orders result. type OrderbookResult struct { LimitOrders []LimitOrder // The channel on which the orders are delivered. diff --git a/domain/orderbook/order_test.go b/domain/orderbook/order_test.go index 4659836fe..6e6af9580 100644 --- a/domain/orderbook/order_test.go +++ b/domain/orderbook/order_test.go @@ -82,49 +82,49 @@ func TestOrdersByDirection(t *testing.T) { testCases := []struct { name string orders orderbookdomain.Orders - direction string + direction orderbookdomain.OrderDirection expectedOrders orderbookdomain.Orders }{ { name: "Filter buy orders", orders: orderbookdomain.Orders{ - {OrderDirection: "buy", OrderId: 1}, - {OrderDirection: "sell", OrderId: 2}, - {OrderDirection: "buy", OrderId: 3}, + {OrderDirection: orderbookdomain.DirectionBid.String(), OrderId: 1}, + {OrderDirection: orderbookdomain.DirectionAsk.String(), OrderId: 2}, + {OrderDirection: orderbookdomain.DirectionBid.String(), OrderId: 3}, }, - direction: "buy", + direction: orderbookdomain.DirectionBid, expectedOrders: orderbookdomain.Orders{ - {OrderDirection: "buy", OrderId: 1}, - {OrderDirection: "buy", OrderId: 3}, + {OrderDirection: orderbookdomain.DirectionBid.String(), OrderId: 1}, + {OrderDirection: orderbookdomain.DirectionBid.String(), OrderId: 3}, }, }, { name: "Filter sell orders", orders: orderbookdomain.Orders{ - {OrderDirection: "buy", OrderId: 1}, - {OrderDirection: "sell", OrderId: 2}, - {OrderDirection: "buy", OrderId: 3}, - {OrderDirection: "sell", OrderId: 4}, + {OrderDirection: orderbookdomain.DirectionBid.String(), OrderId: 1}, + {OrderDirection: orderbookdomain.DirectionAsk.String(), OrderId: 2}, + {OrderDirection: orderbookdomain.DirectionBid.String(), OrderId: 3}, + {OrderDirection: orderbookdomain.DirectionAsk.String(), OrderId: 4}, }, - direction: "sell", + direction: orderbookdomain.DirectionAsk, expectedOrders: orderbookdomain.Orders{ - {OrderDirection: "sell", OrderId: 2}, - {OrderDirection: "sell", OrderId: 4}, + {OrderDirection: orderbookdomain.DirectionAsk.String(), OrderId: 2}, + {OrderDirection: orderbookdomain.DirectionAsk.String(), OrderId: 4}, }, }, { name: "No matching orders", orders: orderbookdomain.Orders{ - {OrderDirection: "buy", OrderId: 1}, - {OrderDirection: "buy", OrderId: 2}, + {OrderDirection: orderbookdomain.DirectionBid.String(), OrderId: 1}, + {OrderDirection: orderbookdomain.DirectionBid.String(), OrderId: 2}, }, - direction: "sell", + direction: orderbookdomain.DirectionAsk, expectedOrders: nil, }, { name: "Empty orders slice", orders: orderbookdomain.Orders{}, - direction: "buy", + direction: orderbookdomain.DirectionBid, expectedOrders: nil, }, } @@ -185,3 +185,76 @@ func TestLimitOrder_IsClaimable(t *testing.T) { }) } } + +func TestClaimableAmount(t *testing.T) { + tests := []struct { + name string + order orderbookdomain.LimitOrder + want osmomath.Dec + }{ + { + name: "Buy 10 OSMO for 1 USD with 50% filled and 0.25% claimed", + order: orderbookdomain.LimitOrder{ + OrderDirection: orderbookdomain.DirectionBid, + TotalFilled: osmomath.NewDec(5), + PercentClaimed: osmomath.MustNewDecFromStr("0.25"), + }, + want: osmomath.MustNewDecFromStr("3.75"), + }, + { + name: "Buy 10 OSMO with 0% filled and 1% claimed", + order: orderbookdomain.LimitOrder{ + OrderDirection: orderbookdomain.DirectionBid, + TotalFilled: osmomath.NewDec(0), + PercentClaimed: osmomath.MustNewDecFromStr("1"), + }, + want: osmomath.MustNewDecFromStr("0"), + }, + { + name: "Buy 10 OSMO with 5% filled and 0% claimed", + order: orderbookdomain.LimitOrder{ + OrderDirection: orderbookdomain.DirectionBid, + TotalFilled: osmomath.NewDec(5), + PercentClaimed: osmomath.MustNewDecFromStr("0"), + }, + want: osmomath.MustNewDecFromStr("5"), + }, + { + name: "Sell 0.1 OSMO for 1 USD, with 50% filled and 0.25% claimed", + order: orderbookdomain.LimitOrder{ + OrderDirection: orderbookdomain.DirectionAsk, + Output: osmomath.NewDec(10), + TotalFilled: osmomath.MustNewDecFromStr("0.5"), + PercentClaimed: osmomath.MustNewDecFromStr("0.25"), + }, + want: osmomath.MustNewDecFromStr("0.375"), + }, + { + name: "Sell 0.1 OSMO for 1 USD, with 0% filled and 2% claimed", + order: orderbookdomain.LimitOrder{ + OrderDirection: orderbookdomain.DirectionAsk, + Output: osmomath.NewDec(10), + TotalFilled: osmomath.NewDec(0), + PercentClaimed: osmomath.MustNewDecFromStr("0.2"), + }, + want: osmomath.NewDec(0), + }, + { + name: "Sell 0.1 OSMO for 1 USD, with 3% filled and 0% claimed", + order: orderbookdomain.LimitOrder{ + OrderDirection: orderbookdomain.DirectionAsk, + Output: osmomath.NewDec(10), + TotalFilled: osmomath.MustNewDecFromStr("0.3"), + PercentClaimed: osmomath.MustNewDecFromStr("0"), + }, + want: osmomath.MustNewDecFromStr("0.3"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.order.ClaimableAmount() + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/orderbook/usecase/orderbook_usecase.go b/orderbook/usecase/orderbook_usecase.go index a0227d911..1aded2602 100644 --- a/orderbook/usecase/orderbook_usecase.go +++ b/orderbook/usecase/orderbook_usecase.go @@ -388,7 +388,8 @@ func (o *OrderbookUseCaseImpl) CreateFormattedLimitOrder(orderbook domain.Canoni // Determine tick values and unrealized cancels based on order direction var tickEtas, tickUnrealizedCancelled osmomath.Dec - if order.OrderDirection == "bid" { + + if orderbookdomain.NewDirection(order.OrderDirection).Is(orderbookdomain.DirectionBid) { tickEtas, err = osmomath.NewDecFromStr(tickState.BidValues.EffectiveTotalAmountSwapped) if err != nil { return orderbookdomain.LimitOrder{}, types.ParsingTickValuesError{ @@ -460,7 +461,7 @@ func (o *OrderbookUseCaseImpl) CreateFormattedLimitOrder(orderbook domain.Canoni // Calculate output based on order direction var output osmomath.Dec - if order.OrderDirection == "bid" { + if orderbookdomain.NewDirection(order.OrderDirection).Is(orderbookdomain.DirectionBid) { output = placedQuantity.Quo(price.Dec()) } else { output = placedQuantity.Mul(price.Dec()) @@ -483,7 +484,7 @@ func (o *OrderbookUseCaseImpl) CreateFormattedLimitOrder(orderbook domain.Canoni return orderbookdomain.LimitOrder{ TickId: order.TickId, OrderId: order.OrderId, - OrderDirection: order.OrderDirection, + OrderDirection: orderbookdomain.NewDirection(order.OrderDirection), Owner: order.Owner, Quantity: quantity, Etas: order.Etas, @@ -538,12 +539,12 @@ func (o *OrderbookUseCaseImpl) getClaimableOrdersForTick( return nil, nil // nothing to process } - askClaimable, err := o.getClaimableOrders(orderbook, orders.OrderByDirection("ask"), tick.TickState.AskValues, fillThreshold) + askClaimable, err := o.getClaimableOrders(orderbook, orders.OrderByDirection(orderbookdomain.DirectionAsk), tick.TickState.AskValues, fillThreshold) if err != nil { return nil, err } - bidClaimable, err := o.getClaimableOrders(orderbook, orders.OrderByDirection("bid"), tick.TickState.BidValues, fillThreshold) + bidClaimable, err := o.getClaimableOrders(orderbook, orders.OrderByDirection(orderbookdomain.DirectionBid), tick.TickState.BidValues, fillThreshold) if err != nil { return nil, err } diff --git a/passthrough/usecase/export_test.go b/passthrough/usecase/export_test.go index 164591542..89683f22a 100644 --- a/passthrough/usecase/export_test.go +++ b/passthrough/usecase/export_test.go @@ -15,6 +15,7 @@ const ( InLocksAssetsCategoryName = inLocksAssetsCategoryName PooledAssetsCategoryName = pooledAssetsCategoryName UnclaimedRewardsAssetsCategoryName = unclaimedRewardsAssetsCategoryName + LimitOrdersCategoryName = limitOrdersCategoryName TotalAssetsCategoryName = totalAssetsCategoryName ) diff --git a/passthrough/usecase/passthrough_usecase.go b/passthrough/usecase/passthrough_usecase.go index 314d1880a..23351a19f 100644 --- a/passthrough/usecase/passthrough_usecase.go +++ b/passthrough/usecase/passthrough_usecase.go @@ -17,9 +17,9 @@ import ( ) type passthroughUseCase struct { - poolsUseCase mvc.PoolsUsecase - + poolsUseCase mvc.PoolsUsecase tokensUseCase mvc.TokensUsecase + orderbookUseCase mvc.OrderBookUsecase defaultQuoteDenom string liquidityPricer domain.LiquidityPricer passthroughGRPCClient passthroughdomain.PassthroughGRPCClient @@ -34,6 +34,7 @@ const ( inLocksAssetsCategoryName string = "in-locks" pooledAssetsCategoryName string = "pooled" unclaimedRewardsAssetsCategoryName string = "unclaimed-rewards" + limitOrdersCategoryName string = "limit-orders" totalAssetsCategoryName string = "total-assets" ) @@ -86,9 +87,9 @@ const ( denomShareSeparator = "/" denomShareSeparatorByte = '/' - numFinalResultJobs = 7 + numFinalResultJobs = 8 - totalAssetCompositionNumJobs = 6 + totalAssetCompositionNumJobs = 7 // Number of pooled balance jobs to fetch concurrently. // 1. Gamm shares from user balances @@ -105,17 +106,23 @@ const ( ) // NewPassThroughUsecase Creates a passthrough use case -func NewPassThroughUsecase(passthroughGRPCClient passthroughdomain.PassthroughGRPCClient, puc mvc.PoolsUsecase, tokensUseCase mvc.TokensUsecase, liquidityPricer domain.LiquidityPricer, defaultQuoteDenom string, logger log.Logger) *passthroughUseCase { +func NewPassThroughUsecase( + passthroughGRPCClient passthroughdomain.PassthroughGRPCClient, + poolsUseCase mvc.PoolsUsecase, + tokensUseCase mvc.TokensUsecase, + orderbookUseCase mvc.OrderBookUsecase, + liquidityPricer domain.LiquidityPricer, + defaultQuoteDenom string, + logger log.Logger, +) *passthroughUseCase { return &passthroughUseCase{ - poolsUseCase: puc, - passthroughGRPCClient: passthroughGRPCClient, - - tokensUseCase: tokensUseCase, - defaultQuoteDenom: defaultQuoteDenom, - liquidityPricer: liquidityPricer, - - logger: logger, + poolsUseCase: poolsUseCase, + tokensUseCase: tokensUseCase, + orderbookUseCase: orderbookUseCase, + defaultQuoteDenom: defaultQuoteDenom, + liquidityPricer: liquidityPricer, + logger: logger, } } @@ -238,6 +245,18 @@ func (p *passthroughUseCase) GetPortfolioAssets(ctx context.Context, address str return unclaimedCoins, finalErr } + getLimitOrderCoins := func(ctx context.Context, address string) (sdk.Coins, error) { + orders, _, err := p.orderbookUseCase.GetActiveOrders(ctx, address) + var limitOrdersCoins sdk.Coins + for _, order := range orders { + limitOrdersCoins = limitOrdersCoins.Add(sdk.NewCoin( + order.BaseAsset.Symbol, + order.ClaimableAmount().Ceil().TruncateInt(), + )) + } + return limitOrdersCoins, err + } + // Fetch jobs to fetch the portfolio assets concurrently in separate gorooutines. fetchJobs := []fetchBalancesPortfolioAssetsJob{ { @@ -267,6 +286,10 @@ func (p *passthroughUseCase) GetPortfolioAssets(ctx context.Context, address str name: pooledAssetsCategoryName, fetchFn: getPooledCoins, }, + { + name: limitOrdersCategoryName, + fetchFn: getLimitOrderCoins, + }, } totalAssetsCompositionJobs := make(chan totalAssetsCompositionPortfolioAssetsJob, totalAssetCompositionNumJobs) @@ -374,6 +397,7 @@ func (p *passthroughUseCase) GetPortfolioAssets(ctx context.Context, address str // 5. Unclaimed rewards // 6. Pooled // 7. In-locks + // 8. Limit orders for i := 0; i < numFinalResultJobs; i++ { job := <-finalResultsJobs isBestEffort := job.err != nil diff --git a/passthrough/usecase/passthrough_usecase_test.go b/passthrough/usecase/passthrough_usecase_test.go index 4ee5ad568..a619dfdef 100644 --- a/passthrough/usecase/passthrough_usecase_test.go +++ b/passthrough/usecase/passthrough_usecase_test.go @@ -10,6 +10,7 @@ import ( "github.com/osmosis-labs/osmosis/osmomath" "github.com/osmosis-labs/sqs/domain" "github.com/osmosis-labs/sqs/domain/mocks" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" passthroughdomain "github.com/osmosis-labs/sqs/domain/passthrough" "github.com/osmosis-labs/sqs/log" "github.com/osmosis-labs/sqs/passthrough/usecase" @@ -180,7 +181,21 @@ func (s *PassthroughUseCaseTestSuite) TestGetPotrfolioAssets_HappyPath() { }, } - pu := usecase.NewPassThroughUsecase(&grpcClientMock, &poolsUseCaseMock, &tokensUsecaseMock, liquidityPricerMock, USDC, &log.NoOpLogger{}) + orderbookUseCaseMock := mocks.OrderbookUsecaseMock{ + GetActiveOrdersFunc: func(ctx context.Context, address string) ([]orderbookdomain.LimitOrder, bool, error) { + return nil, false, miscError + }, + } + + pu := usecase.NewPassThroughUsecase( + &grpcClientMock, + &poolsUseCaseMock, + &tokensUsecaseMock, + &orderbookUseCaseMock, + liquidityPricerMock, + USDC, + &log.NoOpLogger{}, + ) // System under test actualPortfolioAssets, err := pu.GetPortfolioAssets(context.TODO(), defaultAddress) @@ -208,6 +223,10 @@ func (s *PassthroughUseCaseTestSuite) TestGetPotrfolioAssets_HappyPath() { Capitalization: osmoCapitalization, IsBestEffort: true, }, + usecase.LimitOrdersCategoryName: { + Capitalization: zero, + IsBestEffort: true, + }, usecase.InLocksAssetsCategoryName: { Capitalization: zero, IsBestEffort: true, @@ -368,7 +387,7 @@ func (s *PassthroughUseCaseTestSuite) TestComputeCapitalizationForCoins() { IsValidChainDenomFunc: isValidChainDenomFuncMock, } - pu := usecase.NewPassThroughUsecase(nil, nil, &tokensUsecaseMock, liquidityPricerMock, USDC, &log.NoOpLogger{}) + pu := usecase.NewPassThroughUsecase(nil, nil, &tokensUsecaseMock, nil, liquidityPricerMock, USDC, &log.NoOpLogger{}) // System under test accountCoinsResult, totalCapitalization, err := pu.ComputeCapitalizationForCoins(context.TODO(), tt.coins) @@ -494,7 +513,7 @@ func (s *PassthroughUseCaseTestSuite) TestGetCoinsFromLocks() { }, } - pu := usecase.NewPassThroughUsecase(&grpcClientMock, &poolsUseCaseMock, nil, nil, USDC, &log.NoOpLogger{}) + pu := usecase.NewPassThroughUsecase(&grpcClientMock, &poolsUseCaseMock, nil, nil, nil, USDC, &log.NoOpLogger{}) // System under test actualBalances, err := pu.GetCoinsFromLocks(context.TODO(), tt.address) @@ -579,7 +598,7 @@ func (s *PassthroughUseCaseTestSuite) TestGetAllBalances() { }, } - pu := usecase.NewPassThroughUsecase(&grpcClientMock, &poolsUseCaseMock, nil, nil, USDC, &log.NoOpLogger{}) + pu := usecase.NewPassThroughUsecase(&grpcClientMock, &poolsUseCaseMock, nil, nil, nil, USDC, &log.NoOpLogger{}) // System under test actualBalances, gammShareBalances, err := pu.GetBankBalances(context.TODO(), tt.address) @@ -674,7 +693,7 @@ func (s *PassthroughUseCaseTestSuite) TestHandleGammShares() { }, } - pu := usecase.NewPassThroughUsecase(nil, &poolsUseCaseMock, nil, nil, USDC, &log.NoOpLogger{}) + pu := usecase.NewPassThroughUsecase(nil, &poolsUseCaseMock, nil, nil, nil, USDC, &log.NoOpLogger{}) // System under test actualBalances, err := pu.HandleGammShares(tt.coinIn) From 136f13d51a1d85406446d14e41d95fa962b232c4 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Thu, 21 Nov 2024 09:06:34 +0200 Subject: [PATCH 08/12] BE-632 | Rabbitai comments --- app/sidecar_query_server.go | 3 --- passthrough/usecase/passthrough_usecase.go | 7 ++++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/sidecar_query_server.go b/app/sidecar_query_server.go index 69b71b86f..7d239b386 100644 --- a/app/sidecar_query_server.go +++ b/app/sidecar_query_server.go @@ -192,9 +192,6 @@ func NewSideCarQueryServer(appCodec codec.Codec, config domain.Config, logger lo // Initialize passthrough query use case passthroughUseCase := passthroughUseCase.NewPassThroughUsecase(passthroughGRPCClient, poolsUseCase, tokensUseCase, orderBookUseCase, liquidityPricer, defaultQuoteDenom, logger) - if err != nil { - return nil, err - } // Use the same config to initialize coingecko pricing strategy coingeckPricingConfig := *config.Pricing diff --git a/passthrough/usecase/passthrough_usecase.go b/passthrough/usecase/passthrough_usecase.go index 23351a19f..7c70e79a8 100644 --- a/passthrough/usecase/passthrough_usecase.go +++ b/passthrough/usecase/passthrough_usecase.go @@ -247,13 +247,18 @@ func (p *passthroughUseCase) GetPortfolioAssets(ctx context.Context, address str getLimitOrderCoins := func(ctx context.Context, address string) (sdk.Coins, error) { orders, _, err := p.orderbookUseCase.GetActiveOrders(ctx, address) + if err != nil { + return nil, err + } + var limitOrdersCoins sdk.Coins for _, order := range orders { limitOrdersCoins = limitOrdersCoins.Add(sdk.NewCoin( order.BaseAsset.Symbol, - order.ClaimableAmount().Ceil().TruncateInt(), + order.ClaimableAmount().TruncateInt(), )) } + return limitOrdersCoins, err } From 3c784de5a9fa59b8148cf5e5baee5fdfabd419a0 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Thu, 21 Nov 2024 09:11:18 +0200 Subject: [PATCH 09/12] BE-632 | Do not use NewCoins constructor for performance reasons --- passthrough/usecase/passthrough_usecase.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/passthrough/usecase/passthrough_usecase.go b/passthrough/usecase/passthrough_usecase.go index 7c70e79a8..0ff55f467 100644 --- a/passthrough/usecase/passthrough_usecase.go +++ b/passthrough/usecase/passthrough_usecase.go @@ -253,10 +253,10 @@ func (p *passthroughUseCase) GetPortfolioAssets(ctx context.Context, address str var limitOrdersCoins sdk.Coins for _, order := range orders { - limitOrdersCoins = limitOrdersCoins.Add(sdk.NewCoin( - order.BaseAsset.Symbol, - order.ClaimableAmount().TruncateInt(), - )) + limitOrdersCoins = limitOrdersCoins.Add(sdk.Coin{ + Denom: order.BaseAsset.Symbol, + Amount: order.ClaimableAmount().TruncateInt(), + }) } return limitOrdersCoins, err From b918fb3fd03f02e37fcad4c8a7c79dd75457ee69 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Mon, 9 Dec 2024 14:00:06 +0200 Subject: [PATCH 10/12] BE-632 | Include limit order amounts into balances --- domain/orderbook/order.go | 9 ++++++--- passthrough/usecase/passthrough_usecase.go | 20 ++++++++++++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/domain/orderbook/order.go b/domain/orderbook/order.go index 61bd23a5d..c432c8a89 100644 --- a/domain/orderbook/order.go +++ b/domain/orderbook/order.go @@ -52,9 +52,12 @@ type Order struct { OrderId int64 `json:"order_id"` // The direction for which to place the order, should be either an ask order or a bid order OrderDirection string `json:"order_direction"` - Owner string `json:"owner"` - Quantity string `json:"quantity"` - Etas string `json:"etas"` + // The address of the order owner + Owner string `json:"owner"` + // Quantity is the actual quantity for the order left to be filled + // It gets updated as the order is filled, claimed + Quantity string `json:"quantity"` + Etas string `json:"etas"` // An optional percentage bounty to claim the order, capped at 1% ClaimBounty string `json:"claim_bounty"` // Immutable quantity of the order when placed diff --git a/passthrough/usecase/passthrough_usecase.go b/passthrough/usecase/passthrough_usecase.go index 0ff55f467..b39348118 100644 --- a/passthrough/usecase/passthrough_usecase.go +++ b/passthrough/usecase/passthrough_usecase.go @@ -12,6 +12,7 @@ import ( "github.com/osmosis-labs/osmosis/osmomath" "github.com/osmosis-labs/sqs/domain" "github.com/osmosis-labs/sqs/domain/mvc" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" passthroughdomain "github.com/osmosis-labs/sqs/domain/passthrough" "github.com/osmosis-labs/sqs/log" ) @@ -253,9 +254,24 @@ func (p *passthroughUseCase) GetPortfolioAssets(ctx context.Context, address str var limitOrdersCoins sdk.Coins for _, order := range orders { + denom, err := func() (string, error) { + switch order.OrderDirection { + case orderbookdomain.DirectionAsk: + return order.BaseAsset.Symbol, nil + case orderbookdomain.DirectionBid: + return order.QuoteAsset.Symbol, nil + default: + return "", fmt.Errorf("unknown order direction: %s", order.OrderDirection) + } + }() + if err != nil { + p.logger.Error("unable to get denom for limit order", zap.Error(err)) + continue + } + limitOrdersCoins = limitOrdersCoins.Add(sdk.Coin{ - Denom: order.BaseAsset.Symbol, - Amount: order.ClaimableAmount().TruncateInt(), + Denom: denom, + Amount: order.Quantity.TruncateInt(), }) } From e6d95deb39f8ac70b0baaf7d9587f9d8a4045511 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Wed, 11 Dec 2024 16:14:56 +0200 Subject: [PATCH 11/12] BE-632 | Include limit order coins into user's balance --- passthrough/usecase/passthrough_usecase.go | 77 ++++++++++++++-------- 1 file changed, 48 insertions(+), 29 deletions(-) diff --git a/passthrough/usecase/passthrough_usecase.go b/passthrough/usecase/passthrough_usecase.go index b39348118..eef60f8f9 100644 --- a/passthrough/usecase/passthrough_usecase.go +++ b/passthrough/usecase/passthrough_usecase.go @@ -142,6 +142,9 @@ func (p *passthroughUseCase) GetPortfolioAssets(ctx context.Context, address str unclaimedRewardsChan := make(chan coinsResult, unclaimedRewardsNumJobs) defer close(unclaimedRewardsChan) + limitOrdersChan := make(chan coinsResult) + defer close(limitOrdersChan) + go func() { // Fetch bank balances and gamm shares concurrently bankBalances, gammShareCoins, err := p.getBankBalances(ctx, address) @@ -187,6 +190,14 @@ func (p *passthroughUseCase) GetPortfolioAssets(ctx context.Context, address str } }() + go func() { + limitOrdersCoins, err := p.getLimitOrderCoins(ctx, address) + limitOrdersChan <- coinsResult{ + coins: limitOrdersCoins, + err: err, + } + }() + // Aggregate poold coins callback getPooledCoins := func(ctx context.Context, address string) (sdk.Coins, error) { pooledCoins := sdk.Coins{} @@ -247,35 +258,8 @@ func (p *passthroughUseCase) GetPortfolioAssets(ctx context.Context, address str } getLimitOrderCoins := func(ctx context.Context, address string) (sdk.Coins, error) { - orders, _, err := p.orderbookUseCase.GetActiveOrders(ctx, address) - if err != nil { - return nil, err - } - - var limitOrdersCoins sdk.Coins - for _, order := range orders { - denom, err := func() (string, error) { - switch order.OrderDirection { - case orderbookdomain.DirectionAsk: - return order.BaseAsset.Symbol, nil - case orderbookdomain.DirectionBid: - return order.QuoteAsset.Symbol, nil - default: - return "", fmt.Errorf("unknown order direction: %s", order.OrderDirection) - } - }() - if err != nil { - p.logger.Error("unable to get denom for limit order", zap.Error(err)) - continue - } - - limitOrdersCoins = limitOrdersCoins.Add(sdk.Coin{ - Denom: denom, - Amount: order.Quantity.TruncateInt(), - }) - } - - return limitOrdersCoins, err + limitOrdersResult := <-limitOrdersChan + return limitOrdersResult.coins, limitOrdersResult.err } // Fetch jobs to fetch the portfolio assets concurrently in separate gorooutines. @@ -580,6 +564,41 @@ func (p *passthroughUseCase) getBankBalances(ctx context.Context, address string return balanceCoins.Sort(), gammShareCoins, nil } +// getLimitOrderCoins returns the user's limit order coins +func (p *passthroughUseCase) getLimitOrderCoins(ctx context.Context, address string) (sdk.Coins, error) { + var err error + orders, _, err := p.orderbookUseCase.GetActiveOrders(ctx, address) + if err != nil { + return nil, err + } + + var limitOrdersCoins sdk.Coins + for _, order := range orders { + denom, derr := func() (string, error) { + switch order.OrderDirection { + case orderbookdomain.DirectionAsk: + return order.BaseAsset.Symbol, nil + case orderbookdomain.DirectionBid: + return order.QuoteAsset.Symbol, nil + default: + return "", fmt.Errorf("unknown order direction: %s", order.OrderDirection) + } + }() + if err != nil { + err = derr // update error + p.logger.Error("unable to get denom for limit order", zap.Error(err)) + continue + } + + limitOrdersCoins = limitOrdersCoins.Add(sdk.Coin{ + Denom: denom, + Amount: order.Quantity.TruncateInt(), + }) + } + + return limitOrdersCoins, err +} + // handleGammShares converts GAMM shares to underlying coins // Returns error if fails to convert GAMM shares to underlying coins. // Returns the underlying coins if successful. From 8f939b0fdf2ea691f492b5271fc805de6bd431e9 Mon Sep 17 00:00:00 2001 From: Deividas Petraitis Date: Tue, 17 Dec 2024 18:06:48 +0200 Subject: [PATCH 12/12] BE-632 | Filter low liquidity denoms --- passthrough/usecase/passthrough_usecase.go | 59 ++++++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/passthrough/usecase/passthrough_usecase.go b/passthrough/usecase/passthrough_usecase.go index eef60f8f9..846a7d014 100644 --- a/passthrough/usecase/passthrough_usecase.go +++ b/passthrough/usecase/passthrough_usecase.go @@ -15,6 +15,7 @@ import ( orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" passthroughdomain "github.com/osmosis-labs/sqs/domain/passthrough" "github.com/osmosis-labs/sqs/log" + api "github.com/osmosis-labs/sqs/pkg/api/v1beta1/pools" ) type passthroughUseCase struct { @@ -60,7 +61,7 @@ type coinsResult struct { // totalAssetsCompositionPortfolioAssetsJob represents a job to compose the total portfolio assets // from the fetched balances. -// Total assets = user balances + staked + unstaking + (pooled - in-locks) + unclaimed-rewards +// Total assets = user balances + staked + unstaking + (pooled - in-locks) + limit orders type totalAssetsCompositionPortfolioAssetsJob struct { // name of the category name string @@ -372,6 +373,9 @@ func (p *passthroughUseCase) GetPortfolioAssets(ctx context.Context, address str continue } + _, totalAssetsCap, err := p.computeCapitalizationForCoins(ctx, job.coins) + p.logger.Info("total assets composition", zap.String("totalAssetsCategoryName", job.name), zap.Any("Capitalization", totalAssetsCap), zap.Error(err)) + totalAssetsCompositionCoins = totalAssetsCompositionCoins.Add(job.coins...) } @@ -383,6 +387,7 @@ func (p *passthroughUseCase) GetPortfolioAssets(ctx context.Context, address str p.logger.Error("error computing total assets capitalization for total assets composition", zap.Error(err), zap.String("address", address)) } + p.logger.Info("total assets composition", zap.String("totalAssetsCategoryName", totalAssetsCategoryName), zap.Any("Capitalization", totalAssetsCap), zap.Error(err)) finalResultsJobs <- finalResultPortfolioAssetsJob{ name: totalAssetsCategoryName, result: passthroughdomain.PortfolioAssetsCategoryResult{ @@ -399,7 +404,6 @@ func (p *passthroughUseCase) GetPortfolioAssets(ctx context.Context, address str // 2. Total assets - broken down by asset capitalization // 3. Unstaking // 4. Staked - // 5. Unclaimed rewards // 6. Pooled // 7. In-locks // 8. Limit orders @@ -430,8 +434,24 @@ func (p *passthroughUseCase) computeCapitalizationForCoins(ctx context.Context, } } + // p.logger.Info("coinDenomsToPrice", zap.Any("coinDenomsToPrice", coinDenomsToPrice), zap.Any("defaultQuoteDenom", p.defaultQuoteDenom)) // Compute prices for the final coins - priceResult, err := p.tokensUseCase.GetPrices(ctx, coinDenomsToPrice, []string{p.defaultQuoteDenom}, domain.ChainPricingSourceType) + opts := []domain.PricingOption{} + + // TODO: Get pools for each coin + // 1. Get pools for each coin + // 2. Get the liquidity cap for each pool, if it's less than the min liquidity cap, zero the price + // 3. Get the price for each coin + // 4. Calculate the capitalization for each coin + + var denoms []string + denomCap := make(map[string]osmomath.BigDec) + for _, v := range coins { + denoms = append(denoms, v.Denom) + denomCap[v.Denom] = osmomath.ZeroBigDec() + } + + priceResult, err := p.tokensUseCase.GetPrices(ctx, coinDenomsToPrice, []string{p.defaultQuoteDenom}, domain.ChainPricingSourceType, opts...) if err != nil { // Instead of returning an error, attempt to return a best-effort result // where all prices are zero. @@ -441,9 +461,40 @@ func (p *passthroughUseCase) computeCapitalizationForCoins(ctx context.Context, // Instrument coins with prices coinsWithPrices := make([]passthroughdomain.AccountCoinsResult, 0, len(coins)) capitalizationTotal := osmomath.ZeroDec() + pools, _, err := p.poolsUseCase.GetPools(domain.WithFilter(&api.GetPoolsRequestFilter{ + Denom: denoms, + })) + for _, pool := range pools { + for _, balance := range pool.GetSQSPoolModel().Balances { + v, ok := denomCap[balance.Denom] + if !ok { + continue + } + + scalingFactor, err := p.tokensUseCase.GetChainScalingFactorByDenomMut(balance.Denom) + if err != nil { + continue + } + + price := priceResult.GetPriceForDenom(balance.Denom, p.defaultQuoteDenom) + amount := osmomath.BigDecFromSDKInt(balance.Amount) + + capitalization := price.Mul(amount.Quo(osmomath.BigDecFromDec(scalingFactor))) + if capitalization.GT(v) { + denomCap[balance.Denom] = capitalization + } + } + } for _, coin := range coins { - price := priceResult.GetPriceForDenom(coin.Denom, p.defaultQuoteDenom) + var price osmomath.BigDec + v, ok := denomCap[coin.Denom] + if !ok || v.LT(osmomath.NewBigDec(500)) { + price = osmomath.ZeroBigDec() + fmt.Println("price: ", coin.Denom) + } else { + price = priceResult.GetPriceForDenom(coin.Denom, p.defaultQuoteDenom) + } coinCapitalization := p.liquidityPricer.PriceCoin(coin, price)