diff --git a/Makefile b/Makefile index 4c9c0494..5e9bf819 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 2b55868d..e0aa9023 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,29 @@ 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, + orderBookRepository, + orderBookAPIClient, + 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 6b88f18b..0489aaa7 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 a29c8718..ce13a69e 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 2c667352..5c7353e0 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/orderbook/plugin/config.go b/domain/orderbook/plugin/config.go index 37139f10..4b8004a9 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 64341394..ceb56098 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 00000000..24082876 --- /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 00000000..e8dc6d98 --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/config.go @@ -0,0 +1,62 @@ +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" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" + orderbookgrpcclientdomain "github.com/osmosis-labs/sqs/domain/orderbook/grpcclient" + "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 + OrderbookRepository orderbookdomain.OrderBookRepository + OrderBookClient orderbookgrpcclientdomain.OrderBookClient + 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, + orderbookRepository orderbookdomain.OrderBookRepository, + orderBookClient orderbookgrpcclientdomain.OrderBookClient, + 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, + OrderbookRepository: orderbookRepository, + OrderBookClient: orderBookClient, + 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 00000000..2bea623d --- /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 00000000..303178a1 --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/export_test.go @@ -0,0 +1,66 @@ +package claimbot + +import ( + "context" + + "github.com/osmosis-labs/sqs/domain" + 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" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" + orderbookgrpcclientdomain "github.com/osmosis-labs/sqs/domain/orderbook/grpcclient" + "github.com/osmosis-labs/sqs/log" + + 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" +) + +// Order is order alias data structure for testing purposes. +type Order = order + +// ProcessOrderbooksAndGetClaimableOrders is test wrapper for processOrderbooksAndGetClaimableOrders. +// This function is exported for testing purposes. +func ProcessOrderbooksAndGetClaimableOrders( + ctx context.Context, + fillThreshold osmomath.Dec, + orderbooks []domain.CanonicalOrderBooksResult, + orderbookRepository orderbookdomain.OrderBookRepository, + orderBookClient orderbookgrpcclientdomain.OrderBookClient, + orderbookusecase mvc.OrderBookUsecase, + logger log.Logger, +) []Order { + return processOrderbooksAndGetClaimableOrders(ctx, fillThreshold, orderbooks, orderbookRepository, orderBookClient, orderbookusecase, logger) +} + +// SendBatchClaimTx a test wrapper for sendBatchClaimTx. +// This function is used only for testing purposes. +func SendBatchClaimTx( + ctx context.Context, + keyring keyring.Keyring, + accountQueryClient authtypes.QueryClient, + txfeesClient txfeestypes.QueryClient, + gasCalculator sqstx.GasCalculator, + txServiceClient txtypes.ServiceClient, + chainID string, + contractAddress string, + claims orderbookdomain.Orders, +) (*sdk.TxResponse, error) { + return sendBatchClaimTx(ctx, keyring, accountQueryClient, txfeesClient, gasCalculator, txServiceClient, chainID, 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, blockHeight uint64, metadata domain.BlockPoolMetadata) ([]domain.CanonicalOrderBooksResult, error) { + return getOrderbooks(poolsUsecase, blockHeight, metadata) +} diff --git a/ingest/usecase/plugins/orderbook/claimbot/order.go b/ingest/usecase/plugins/orderbook/claimbot/order.go new file mode 100644 index 00000000..2a75696b --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/order.go @@ -0,0 +1,183 @@ +package claimbot + +import ( + "context" + "fmt" + + "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" + orderbookgrpcclientdomain "github.com/osmosis-labs/sqs/domain/orderbook/grpcclient" + "github.com/osmosis-labs/sqs/log" + + "go.uber.org/zap" +) + +type order struct { + Orderbook domain.CanonicalOrderBooksResult + Orders orderbookdomain.Orders + Err error +} + +// processOrderbooksAndGetClaimableOrders processes a list of orderbooks and returns claimable orders for each. +func processOrderbooksAndGetClaimableOrders( + ctx context.Context, + fillThreshold osmomath.Dec, + orderbooks []domain.CanonicalOrderBooksResult, + orderbookRepository orderbookdomain.OrderBookRepository, + orderBookClient orderbookgrpcclientdomain.OrderBookClient, + orderbookusecase mvc.OrderBookUsecase, + logger log.Logger, +) []order { + var result []order + for _, orderbook := range orderbooks { + processedOrder := processOrderbook(ctx, fillThreshold, orderbook, orderbookRepository, orderBookClient, orderbookusecase, logger) + result = append(result, processedOrder) + } + return result +} + +// processOrderbook processes a single orderbook and returns an order struct containing the processed orderbook and its claimable orders. +func processOrderbook( + ctx context.Context, + fillThreshold osmomath.Dec, + orderbook domain.CanonicalOrderBooksResult, + orderbookRepository orderbookdomain.OrderBookRepository, + orderBookClient orderbookgrpcclientdomain.OrderBookClient, + orderbookusecase mvc.OrderBookUsecase, + logger log.Logger, +) order { + claimable, err := getClaimableOrdersForOrderbook(ctx, fillThreshold, orderbook, orderbookRepository, orderBookClient, orderbookusecase, logger) + if err != nil { + return order{ + Orderbook: orderbook, + Err: err, + } + } + return order{ + Orderbook: orderbook, + Orders: claimable, + } +} + +// 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. +func getClaimableOrdersForOrderbook( + ctx context.Context, + fillThreshold osmomath.Dec, + orderbook domain.CanonicalOrderBooksResult, + orderbookRepository orderbookdomain.OrderBookRepository, + orderBookClient orderbookgrpcclientdomain.OrderBookClient, + orderbookusecase mvc.OrderBookUsecase, + logger log.Logger, +) (orderbookdomain.Orders, error) { + ticks, ok := orderbookRepository.GetAllTicks(orderbook.PoolID) + if !ok { + return nil, fmt.Errorf("no ticks for orderbook") + } + + var claimable orderbookdomain.Orders + for _, t := range ticks { + tickClaimable, err := getClaimableOrdersForTick(ctx, fillThreshold, orderbook, t, orderBookClient, orderbookusecase, logger) + if err != nil { + logger.Error("error processing tick", zap.String("orderbook", orderbook.ContractAddress), zap.Int64("tick", t.Tick.TickId), zap.Error(err)) + continue + } + claimable = append(claimable, tickClaimable...) + } + + return claimable, 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 getClaimableOrdersForTick( + ctx context.Context, + fillThreshold osmomath.Dec, + orderbook domain.CanonicalOrderBooksResult, + tick orderbookdomain.OrderbookTick, + orderBookClient orderbookgrpcclientdomain.OrderBookClient, + orderbookusecase mvc.OrderBookUsecase, + logger log.Logger, +) (orderbookdomain.Orders, error) { + orders, err := orderBookClient.GetOrdersByTick(ctx, orderbook.ContractAddress, tick.Tick.TickId) + if err != nil { + return nil, fmt.Errorf("unable to fetch orderbook orders by tick ID: %w", err) + } + + if len(orders) == 0 { + return nil, nil + } + + askClaimable := getClaimableOrders(orderbook, orders.OrderByDirection("ask"), tick.TickState.AskValues, fillThreshold, orderbookusecase, logger) + bidClaimable := getClaimableOrders(orderbook, orders.OrderByDirection("bid"), tick.TickState.BidValues, fillThreshold, orderbookusecase, logger) + + 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 getClaimableOrders( + orderbook domain.CanonicalOrderBooksResult, + orders orderbookdomain.Orders, + tickValues orderbookdomain.TickValues, + fillThreshold osmomath.Dec, + orderbookusecase mvc.OrderBookUsecase, + logger log.Logger, +) orderbookdomain.Orders { + if isTickFullyFilled(tickValues) { + return orders + } + + return filterClaimableOrders(orderbook, orders, fillThreshold, orderbookusecase, logger) +} + +// isTickFullyFilled checks if a tick is fully filled by comparing its cumulative total value +// to its effective total amount swapped. +func isTickFullyFilled(tickValues orderbookdomain.TickValues) bool { + if len(tickValues.CumulativeTotalValue) == 0 || len(tickValues.EffectiveTotalAmountSwapped) == 0 { + return false // empty values, thus not fully filled + } + return tickValues.CumulativeTotalValue == tickValues.EffectiveTotalAmountSwapped +} + +// filterClaimableOrders processes a list of orders and returns only those that are considered claimable. +func filterClaimableOrders( + orderbook domain.CanonicalOrderBooksResult, + orders orderbookdomain.Orders, + fillThreshold osmomath.Dec, + orderbookusecase mvc.OrderBookUsecase, + logger log.Logger, +) orderbookdomain.Orders { + var claimable orderbookdomain.Orders + for _, order := range orders { + if isOrderClaimable(orderbook, order, fillThreshold, orderbookusecase, logger) { + claimable = append(claimable, order) + } + } + return claimable +} + +// isOrderClaimable determines if a single order is claimable based on the fill threshold. +func isOrderClaimable( + orderbook domain.CanonicalOrderBooksResult, + order orderbookdomain.Order, + fillThreshold osmomath.Dec, + orderbookusecase mvc.OrderBookUsecase, + logger log.Logger, +) bool { + result, err := orderbookusecase.CreateFormattedLimitOrder(orderbook, order) + if err != nil { + logger.Info( + "unable to create orderbook limit order; marking as not claimable", + zap.String("orderbook", orderbook.ContractAddress), + zap.Int64("order", order.OrderId), + zap.Error(err), + ) + return false + } + return result.IsClaimable(fillThreshold) +} 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 00000000..97306a06 --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/order_test.go @@ -0,0 +1,175 @@ +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/osmosis-labs/sqs/log" + "github.com/osmosis-labs/sqs/sqsdomain/cosmwasmpool" + + "github.com/stretchr/testify/assert" +) + +func TestProcessOrderbooksAndGetClaimableOrders(t *testing.T) { + newOrderbookTick := func(tickID int64) map[int64]orderbookdomain.OrderbookTick { + return map[int64]orderbookdomain.OrderbookTick{ + tickID: { + Tick: &cosmwasmpool.OrderbookTick{ + TickId: tickID, + }, + }, + } + } + + newOrderbookFullyFilledTick := func(tickID int64, direction string) map[int64]orderbookdomain.OrderbookTick { + tick := orderbookdomain.OrderbookTick{ + Tick: &cosmwasmpool.OrderbookTick{ + TickId: tickID, + }, + TickState: orderbookdomain.TickState{}, + } + + tickValue := orderbookdomain.TickValues{ + CumulativeTotalValue: "100", + EffectiveTotalAmountSwapped: "100", + } + + if direction == "bid" { + tick.TickState.BidValues = tickValue + } else { + tick.TickState.AskValues = tickValue + } + + return map[int64]orderbookdomain.OrderbookTick{ + tickID: tick, + } + } + + newOrder := func(direction string) orderbookdomain.Order { + return orderbookdomain.Order{ + TickId: 1, + OrderId: 1, + OrderDirection: direction, + } + } + + newLimitOrder := func(percentFilled osmomath.Dec) orderbookdomain.LimitOrder { + return orderbookdomain.LimitOrder{ + OrderId: 1, + PercentFilled: percentFilled, + } + } + + 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.OrderbookRepositoryMock, *mocks.OrderbookGRPCClientMock, *mocks.OrderbookUsecaseMock) + expectedOrders []claimbot.Order + }{ + { + name: "No orderbooks", + fillThreshold: osmomath.NewDec(1), + orderbooks: []domain.CanonicalOrderBooksResult{}, + mockSetup: func(repo *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, usecase *mocks.OrderbookUsecaseMock) { + repo.GetAllTicksFunc = func(poolID uint64) (map[int64]orderbookdomain.OrderbookTick, bool) { + return nil, false + } + }, + expectedOrders: nil, + }, + { + name: "Single orderbook with no claimable orders", + fillThreshold: osmomath.NewDecWithPrec(95, 2), // 0.95 + orderbooks: []domain.CanonicalOrderBooksResult{ + newCanonicalOrderBooksResult(10, "contract1"), + }, + mockSetup: func(repository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, usecase *mocks.OrderbookUsecaseMock) { + repository.WithGetAllTicksFunc(newOrderbookTick(1), true) + + client.WithGetOrdersByTickCb(orderbookdomain.Orders{ + newOrder("ask"), + }, nil) + + // Not claimable order, below threshold + usecase.WithCreateFormattedLimitOrder(newLimitOrder(osmomath.NewDecWithPrec(90, 2)), nil) + }, + expectedOrders: []claimbot.Order{ + { + 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(repository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, usecase *mocks.OrderbookUsecaseMock) { + repository.WithGetAllTicksFunc(newOrderbookFullyFilledTick(35, "bid"), true) + + client.WithGetOrdersByTickCb(orderbookdomain.Orders{ + newOrder("bid"), + }, nil) + + usecase.WithCreateFormattedLimitOrder(newLimitOrder(osmomath.NewDecWithPrec(90, 2)), nil) + }, + expectedOrders: []claimbot.Order{ + { + Orderbook: newCanonicalOrderBooksResult(38, "contract8"), + Orders: orderbookdomain.Orders{newOrder("bid")}, + }, + }, + }, + { + name: "Orderbook with claimable orders", + fillThreshold: osmomath.NewDecWithPrec(95, 2), // 0.95 + orderbooks: []domain.CanonicalOrderBooksResult{ + newCanonicalOrderBooksResult(64, "contract58"), + }, + mockSetup: func(repository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, usecase *mocks.OrderbookUsecaseMock) { + repository.WithGetAllTicksFunc(newOrderbookTick(42), true) + + client.WithGetOrdersByTickCb(orderbookdomain.Orders{ + newOrder("ask"), + }, nil) + + // Claimable order, above threshold + usecase.WithCreateFormattedLimitOrder(newLimitOrder(osmomath.NewDecWithPrec(96, 2)), nil) + }, + expectedOrders: []claimbot.Order{ + { + Orderbook: newCanonicalOrderBooksResult(64, "contract58"), + Orders: orderbookdomain.Orders{newOrder("ask")}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + repository := mocks.OrderbookRepositoryMock{} + client := mocks.OrderbookGRPCClientMock{} + usecase := mocks.OrderbookUsecaseMock{} + logger := log.NoOpLogger{} + + tt.mockSetup(&repository, &client, &usecase) + + result := claimbot.ProcessOrderbooksAndGetClaimableOrders(ctx, tt.fillThreshold, tt.orderbooks, &repository, &client, &usecase, &logger) + + 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 00000000..f93c032e --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/orderbook.go @@ -0,0 +1,24 @@ +package claimbot + +import ( + "fmt" + + "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, blockHeight uint64, metadata domain.BlockPoolMetadata) ([]domain.CanonicalOrderBooksResult, error) { + orderbooks, err := poolsUsecase.GetAllCanonicalOrderbookPoolIDs() + if err != nil { + return nil, fmt.Errorf("failed to get all canonical orderbook pool IDs ( block height %d ) : %w", blockHeight, 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 00000000..9bf78836 --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/orderbook_test.go @@ -0,0 +1,81 @@ +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 + blockHeight uint64 + metadata domain.BlockPoolMetadata + setupMocks func(*mocks.PoolsUsecaseMock) + want []domain.CanonicalOrderBooksResult + err bool + }{ + { + name: "Metadata contains all canonical orderbooks but one", + blockHeight: 1000, + 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", + blockHeight: 1893, + 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", + blockHeight: 2000, + 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.blockHeight, 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 00000000..0fa828af --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/plugin.go @@ -0,0 +1,160 @@ +package claimbot + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + "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" + orderbookgrpcclientdomain "github.com/osmosis-labs/sqs/domain/orderbook/grpcclient" + "github.com/osmosis-labs/sqs/domain/slices" + "github.com/osmosis-labs/sqs/log" + + "github.com/osmosis-labs/osmosis/osmomath" + + "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") + blockInclusionWaitTime = 5 * time.Second +) + +// 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, + orderbookRepository orderbookdomain.OrderBookRepository, + orderBookClient orderbookgrpcclientdomain.OrderBookClient, + logger log.Logger, + chainGRPCGatewayEndpoint string, + chainID string, +) (*claimbot, error) { + config, err := NewConfig(keyring, orderbookusecase, poolsUsecase, orderbookRepository, orderBookClient, 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) + + orderbooks, err := getOrderbooks(o.config.PoolsUseCase, blockHeight, metadata) + if err != nil { + return err + } + + // retrieve claimable orders for the orderbooks + orders := processOrderbooksAndGetClaimableOrders( + ctx, + fillThreshold, + orderbooks, + o.config.OrderbookRepository, + o.config.OrderBookClient, + o.config.OrderbookUsecase, + o.config.Logger, + ) + + for _, orderbook := range orders { + if orderbook.Err != nil { + o.config.Logger.Warn( + "failed to retrieve claimable orders", + zap.String("contract_address", orderbook.Orderbook.ContractAddress), + zap.Error(orderbook.Err), + ) + continue + } + + if err := o.processOrderbookOrders(ctx, orderbook.Orderbook, orderbook.Orders); err != nil { + o.config.Logger.Warn( + "failed to process orderbook orders", + zap.String("contract_address", orderbook.Orderbook.ContractAddress), + zap.Error(err), + ) + } + } + + o.config.Logger.Info("processed end block", zap.Uint64("block_height", blockHeight)) + + return nil +} + +// processOrderbookOrders processes a batch of claimable orders. +func (o *claimbot) processOrderbookOrders(ctx context.Context, orderbook domain.CanonicalOrderBooksResult, orders orderbookdomain.Orders) error { + if len(orders) == 0 { + return fmt.Errorf("no claimable orders found for orderbook %s, nothing to process", orderbook.ContractAddress) + } + + for _, chunk := range slices.Split(orders, maxBatchOfClaimableOrders) { + if len(chunk) == 0 { + continue + } + + txres, err := sendBatchClaimTx( + ctx, + o.config.Keyring, + o.config.AccountQueryClient, + o.config.TxfeesClient, + o.config.GasCalculator, + o.config.TxServiceClient, + o.config.ChainID, + 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), + ) + } + + // Wait for block inclusion with buffer to avoid sequence mismatch + time.Sleep(blockInclusionWaitTime) + } + + 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 00000000..239357db --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/tx.go @@ -0,0 +1,99 @@ +package claimbot + +import ( + "context" + "encoding/json" + "fmt" + + 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" + 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" +) + +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, + accountQueryClient authtypes.QueryClient, + txfeesClient txfeestypes.QueryClient, + gasCalculator sqstx.GasCalculator, + txServiceClient txtypes.ServiceClient, + chainID string, + contractAddress string, + claims orderbookdomain.Orders, +) (*sdk.TxResponse, error) { + address := keyring.GetAddress().String() + + account, err := accountQueryClient.GetAccount(ctx, address) + if err != nil { + return nil, err + } + + 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) +} + +// 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 := struct { + BatchClaim struct { + Orders [][]int64 `json:"orders"` + } `json:"batch_claim"` + }{ + BatchClaim: struct { + Orders [][]int64 `json:"orders"` + }{ + 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 00000000..9f046e0a --- /dev/null +++ b/ingest/usecase/plugins/orderbook/claimbot/tx_test.go @@ -0,0 +1,175 @@ +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, *mocks.AuthQueryClientMock, *mocks.TxFeesQueryClient, *mocks.GasCalculator, *mocks.TxServiceClient) + setSendTxFunc func() []byte + expectedResponse *sdk.TxResponse + expectedError bool + }{ + { + name: "AuthQueryClient.GetAccountFunc returns error", + contractAddress: "osmo1contractaddress", + claims: orderbookdomain.Orders{ + {TickId: 13, OrderId: 99}, + }, + setupMocks: func(keyringMock *mocks.Keyring, authQueryClient *mocks.AuthQueryClientMock, txfeesClient *mocks.TxFeesQueryClient, gasCalculator *mocks.GasCalculator, txServiceClient *mocks.TxServiceClient) { + keyringMock.WithGetAddress("osmo0address") + keyringMock.WithGetKey("6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159") + authQueryClient.WithGetAccount(nil, assert.AnError) + }, + expectedResponse: &sdk.TxResponse{}, + expectedError: true, + }, + { + name: "BuildTx returns error", + contractAddress: "osmo1contractaddress", + claims: orderbookdomain.Orders{ + {TickId: 13, OrderId: 99}, + }, + setupMocks: func(keyringMock *mocks.Keyring, authQueryClient *mocks.AuthQueryClientMock, txfeesClient *mocks.TxFeesQueryClient, gasCalculator *mocks.GasCalculator, txServiceClient *mocks.TxServiceClient) { + keyringMock.WithGetAddress("osmo0address") + keyringMock.WithGetKey("6cf5103c60c939a5f38e383b52239c5296c968579eec1c68a47d70fbf1d19159") + authQueryClient.WithGetAccount(&authtypes.BaseAccount{ + AccountNumber: 3, + Sequence: 31, + }, nil) + 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, authQueryClient *mocks.AuthQueryClientMock, 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) + authQueryClient.WithGetAccount(&authtypes.BaseAccount{ + AccountNumber: 83, + Sequence: 5, + }, nil) + 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, authQueryClient *mocks.AuthQueryClientMock, 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) + authQueryClient.WithGetAccount(&authtypes.BaseAccount{ + AccountNumber: 1, + Sequence: 1, + }, nil) + + 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]]}}\x12b\nP\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\x18\x01\x12\x0e\n\n\n\x05uosmo\x12\x018\x103\x1a@Xߠ&\xea\xb8\x0e\xefؓf\xb3\xe7DMӡW\x99h\u008e\xbdh\xef\\\xd3\xd7\x02\xf1\xdc\xe1&\r\x91\xdd\xcdtu\xee\xdeJ\x90\x1a\x7f\xb2(L\x15\xe0+'\xf5\xe3\fV\t3!\xa2,\x802z", + }, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + keyring := mocks.Keyring{} + authQueryClient := mocks.AuthQueryClientMock{} + txFeesClient := mocks.TxFeesQueryClient{} + gasCalculator := mocks.GasCalculator{} + txServiceClient := mocks.TxServiceClient{} + + tt.setupMocks(&keyring, &authQueryClient, &txFeesClient, &gasCalculator, &txServiceClient) + + response, err := claimbot.SendBatchClaimTx(ctx, &keyring, &authQueryClient, &txFeesClient, &gasCalculator, &txServiceClient, tt.chainID, 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 af669959..fd3973f0 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 01d6ae4a..c219306d 100755 --- a/ingest/usecase/plugins/orderbook/fillbot/create_copy_config.sh +++ b/ingest/usecase/plugins/orderbook/fillbot/create_copy_config.sh @@ -1,9 +1,14 @@ -#!/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 $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 08745863..9a8332ab 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/router/repository/memory_router_repository_test.go b/router/repository/memory_router_repository_test.go index 22b4c6a9..64a13da2 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" )