diff --git a/.swaggo b/.swaggo new file mode 100644 index 000000000..ba4a73292 --- /dev/null +++ b/.swaggo @@ -0,0 +1,5 @@ +// Swagger overrides file +// @link https://github.com/swaggo/swag?tab=readme-ov-file#use-global-overrides-to-support-a-custom-type + +// Replace all Dec with string +replace osmomath.Dec string diff --git a/Makefile b/Makefile index 9936c9b33..f1a24ac05 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ generate-mocks: mockery bin/mockery --config mockery.yaml swagger-gen: - $(HOME)/go/bin/swag init -g app/main.go + $(HOME)/go/bin/swag init -g app/main.go --pd --overridesFile ./.swaggo run: go run -ldflags="-X github.com/osmosis-labs/sqs/version=${VERSION}" app/*.go --config config.json diff --git a/docs/docs.go b/docs/docs.go index ac1c931de..ca7dc4dd3 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -15,6 +15,44 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/passthrough/active-orders": { + "get": { + "description": "The returned data represents all active orders for all orderbooks available for the specified address.", + "produces": [ + "application/json" + ], + "summary": "Returns all active orderbook orders associated with the given address.", + "parameters": [ + { + "type": "string", + "description": "Osmo wallet address", + "name": "userOsmoAddress", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "List of active orders for all available orderboooks for the given address", + "schema": { + "$ref": "#/definitions/github_com_osmosis-labs_sqs_orderbook_types.GetActiveOrdersResponse" + } + }, + "400": { + "description": "Response error", + "schema": { + "$ref": "#/definitions/domain.ResponseError" + } + }, + "500": { + "description": "Response error", + "schema": { + "$ref": "#/definitions/domain.ResponseError" + } + } + } + } + }, "/passthrough/portfolio-assets/{address}": { "get": { "description": "The returned data represents the potfolio asset breakdown by category for the specified address.", @@ -35,13 +73,13 @@ const docTemplate = `{ "200": { "description": "Portfolio assets by-category and capitalization of the entire account value", "schema": { - "type": "struct" + "$ref": "#/definitions/github_com_osmosis-labs_sqs_domain_passthrough.PortfolioAssetsResult" } }, "500": { "description": "Response error", "schema": { - "type": "struct" + "$ref": "#/definitions/domain.ResponseError" } } } @@ -113,7 +151,7 @@ const docTemplate = `{ "200": { "description": "Canonical Orderbook Pool ID for the given base and quote", "schema": { - "type": "struct" + "$ref": "#/definitions/domain.CanonicalOrderBooksResult" } } } @@ -436,6 +474,14 @@ const docTemplate = `{ } } }, + "domain.ResponseError": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, "domain.Token": { "type": "object", "properties": { @@ -464,6 +510,159 @@ const docTemplate = `{ } } }, + "github_com_cosmos_cosmos-sdk_types.Coin": { + "type": "object", + "properties": { + "amount": { + "$ref": "#/definitions/types.Int" + }, + "denom": { + "type": "string" + } + } + }, + "github_com_osmosis-labs_sqs_domain_orderbook.Asset": { + "type": "object", + "properties": { + "symbol": { + "type": "string" + } + } + }, + "github_com_osmosis-labs_sqs_domain_orderbook.LimitOrder": { + "type": "object", + "properties": { + "base_asset": { + "$ref": "#/definitions/github_com_osmosis-labs_sqs_domain_orderbook.Asset" + }, + "claim_bounty": { + "type": "string" + }, + "etas": { + "type": "string" + }, + "order_direction": { + "type": "string" + }, + "order_id": { + "type": "integer" + }, + "orderbookAddress": { + "type": "string" + }, + "output": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "percentClaimed": { + "type": "string" + }, + "percentFilled": { + "type": "string" + }, + "placed_at": { + "type": "integer" + }, + "placed_quantity": { + "type": "string" + }, + "placed_tx": { + "type": "string" + }, + "price": { + "type": "string" + }, + "quantity": { + "type": "string" + }, + "quote_asset": { + "$ref": "#/definitions/github_com_osmosis-labs_sqs_domain_orderbook.Asset" + }, + "status": { + "$ref": "#/definitions/github_com_osmosis-labs_sqs_domain_orderbook.OrderStatus" + }, + "tick_id": { + "type": "integer" + }, + "totalFilled": { + "type": "string" + } + } + }, + "github_com_osmosis-labs_sqs_domain_orderbook.OrderStatus": { + "type": "string", + "enum": [ + "open", + "partiallyFilled", + "filled", + "fullyClaimed", + "cancelled" + ], + "x-enum-varnames": [ + "StatusOpen", + "StatusPartiallyFilled", + "StatusFilled", + "StatusFullyClaimed", + "StatusCancelled" + ] + }, + "github_com_osmosis-labs_sqs_domain_passthrough.AccountCoinsResult": { + "type": "object", + "properties": { + "cap_value": { + "type": "string" + }, + "coin": { + "$ref": "#/definitions/github_com_cosmos_cosmos-sdk_types.Coin" + } + } + }, + "github_com_osmosis-labs_sqs_domain_passthrough.PortfolioAssetsCategoryResult": { + "type": "object", + "properties": { + "account_coins_result": { + "description": "AccountCoinsResult represents coins only from user balances (contrary to TotalValueCap).", + "type": "array", + "items": { + "$ref": "#/definitions/github_com_osmosis-labs_sqs_domain_passthrough.AccountCoinsResult" + } + }, + "capitalization": { + "description": "Capitalization represents the total value of the assets in the portfolio.\nincludes capitalization of user balances, value in locks, bonding or unbonding\nas well as the concentrated positions.", + "type": "string" + }, + "is_best_effort": { + "type": "boolean" + } + } + }, + "github_com_osmosis-labs_sqs_domain_passthrough.PortfolioAssetsResult": { + "type": "object", + "properties": { + "categories": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/github_com_osmosis-labs_sqs_domain_passthrough.PortfolioAssetsCategoryResult" + } + } + } + }, + "github_com_osmosis-labs_sqs_orderbook_types.GetActiveOrdersResponse": { + "type": "object", + "properties": { + "is_best_effort": { + "type": "boolean" + }, + "orders": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_osmosis-labs_sqs_domain_orderbook.LimitOrder" + } + } + } + }, "sqsdomain.CandidatePool": { "type": "object", "properties": { @@ -508,6 +707,9 @@ const docTemplate = `{ } } } + }, + "types.Int": { + "type": "object" } } }` diff --git a/docs/swagger.json b/docs/swagger.json index 4b3f2bd03..0114eaff8 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -6,6 +6,44 @@ "version": "1.0" }, "paths": { + "/passthrough/active-orders": { + "get": { + "description": "The returned data represents all active orders for all orderbooks available for the specified address.", + "produces": [ + "application/json" + ], + "summary": "Returns all active orderbook orders associated with the given address.", + "parameters": [ + { + "type": "string", + "description": "Osmo wallet address", + "name": "userOsmoAddress", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "List of active orders for all available orderboooks for the given address", + "schema": { + "$ref": "#/definitions/github_com_osmosis-labs_sqs_orderbook_types.GetActiveOrdersResponse" + } + }, + "400": { + "description": "Response error", + "schema": { + "$ref": "#/definitions/domain.ResponseError" + } + }, + "500": { + "description": "Response error", + "schema": { + "$ref": "#/definitions/domain.ResponseError" + } + } + } + } + }, "/passthrough/portfolio-assets/{address}": { "get": { "description": "The returned data represents the potfolio asset breakdown by category for the specified address.", @@ -26,13 +64,13 @@ "200": { "description": "Portfolio assets by-category and capitalization of the entire account value", "schema": { - "type": "struct" + "$ref": "#/definitions/github_com_osmosis-labs_sqs_domain_passthrough.PortfolioAssetsResult" } }, "500": { "description": "Response error", "schema": { - "type": "struct" + "$ref": "#/definitions/domain.ResponseError" } } } @@ -104,7 +142,7 @@ "200": { "description": "Canonical Orderbook Pool ID for the given base and quote", "schema": { - "type": "struct" + "$ref": "#/definitions/domain.CanonicalOrderBooksResult" } } } @@ -427,6 +465,14 @@ } } }, + "domain.ResponseError": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, "domain.Token": { "type": "object", "properties": { @@ -455,6 +501,159 @@ } } }, + "github_com_cosmos_cosmos-sdk_types.Coin": { + "type": "object", + "properties": { + "amount": { + "$ref": "#/definitions/types.Int" + }, + "denom": { + "type": "string" + } + } + }, + "github_com_osmosis-labs_sqs_domain_orderbook.Asset": { + "type": "object", + "properties": { + "symbol": { + "type": "string" + } + } + }, + "github_com_osmosis-labs_sqs_domain_orderbook.LimitOrder": { + "type": "object", + "properties": { + "base_asset": { + "$ref": "#/definitions/github_com_osmosis-labs_sqs_domain_orderbook.Asset" + }, + "claim_bounty": { + "type": "string" + }, + "etas": { + "type": "string" + }, + "order_direction": { + "type": "string" + }, + "order_id": { + "type": "integer" + }, + "orderbookAddress": { + "type": "string" + }, + "output": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "percentClaimed": { + "type": "string" + }, + "percentFilled": { + "type": "string" + }, + "placed_at": { + "type": "integer" + }, + "placed_quantity": { + "type": "string" + }, + "placed_tx": { + "type": "string" + }, + "price": { + "type": "string" + }, + "quantity": { + "type": "string" + }, + "quote_asset": { + "$ref": "#/definitions/github_com_osmosis-labs_sqs_domain_orderbook.Asset" + }, + "status": { + "$ref": "#/definitions/github_com_osmosis-labs_sqs_domain_orderbook.OrderStatus" + }, + "tick_id": { + "type": "integer" + }, + "totalFilled": { + "type": "string" + } + } + }, + "github_com_osmosis-labs_sqs_domain_orderbook.OrderStatus": { + "type": "string", + "enum": [ + "open", + "partiallyFilled", + "filled", + "fullyClaimed", + "cancelled" + ], + "x-enum-varnames": [ + "StatusOpen", + "StatusPartiallyFilled", + "StatusFilled", + "StatusFullyClaimed", + "StatusCancelled" + ] + }, + "github_com_osmosis-labs_sqs_domain_passthrough.AccountCoinsResult": { + "type": "object", + "properties": { + "cap_value": { + "type": "string" + }, + "coin": { + "$ref": "#/definitions/github_com_cosmos_cosmos-sdk_types.Coin" + } + } + }, + "github_com_osmosis-labs_sqs_domain_passthrough.PortfolioAssetsCategoryResult": { + "type": "object", + "properties": { + "account_coins_result": { + "description": "AccountCoinsResult represents coins only from user balances (contrary to TotalValueCap).", + "type": "array", + "items": { + "$ref": "#/definitions/github_com_osmosis-labs_sqs_domain_passthrough.AccountCoinsResult" + } + }, + "capitalization": { + "description": "Capitalization represents the total value of the assets in the portfolio.\nincludes capitalization of user balances, value in locks, bonding or unbonding\nas well as the concentrated positions.", + "type": "string" + }, + "is_best_effort": { + "type": "boolean" + } + } + }, + "github_com_osmosis-labs_sqs_domain_passthrough.PortfolioAssetsResult": { + "type": "object", + "properties": { + "categories": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/github_com_osmosis-labs_sqs_domain_passthrough.PortfolioAssetsCategoryResult" + } + } + } + }, + "github_com_osmosis-labs_sqs_orderbook_types.GetActiveOrdersResponse": { + "type": "object", + "properties": { + "is_best_effort": { + "type": "boolean" + }, + "orders": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_osmosis-labs_sqs_domain_orderbook.LimitOrder" + } + } + } + }, "sqsdomain.CandidatePool": { "type": "object", "properties": { @@ -499,6 +698,9 @@ } } } + }, + "types.Int": { + "type": "object" } } } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b34d58f1f..fc8dfcb01 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -10,6 +10,11 @@ definitions: quote: type: string type: object + domain.ResponseError: + properties: + message: + type: string + type: object domain.Token: properties: coinMinimalDenom: @@ -30,6 +35,113 @@ definitions: description: HumanDenom is the human readable denom, e.g. atom type: string type: object + github_com_cosmos_cosmos-sdk_types.Coin: + properties: + amount: + $ref: '#/definitions/types.Int' + denom: + type: string + type: object + github_com_osmosis-labs_sqs_domain_orderbook.Asset: + properties: + symbol: + type: string + type: object + github_com_osmosis-labs_sqs_domain_orderbook.LimitOrder: + properties: + base_asset: + $ref: '#/definitions/github_com_osmosis-labs_sqs_domain_orderbook.Asset' + claim_bounty: + type: string + etas: + type: string + order_direction: + type: string + order_id: + type: integer + orderbookAddress: + type: string + output: + type: string + owner: + type: string + percentClaimed: + type: string + percentFilled: + type: string + placed_at: + type: integer + placed_quantity: + type: string + placed_tx: + type: string + price: + type: string + quantity: + type: string + quote_asset: + $ref: '#/definitions/github_com_osmosis-labs_sqs_domain_orderbook.Asset' + status: + $ref: '#/definitions/github_com_osmosis-labs_sqs_domain_orderbook.OrderStatus' + tick_id: + type: integer + totalFilled: + type: string + type: object + github_com_osmosis-labs_sqs_domain_orderbook.OrderStatus: + enum: + - open + - partiallyFilled + - filled + - fullyClaimed + - cancelled + type: string + x-enum-varnames: + - StatusOpen + - StatusPartiallyFilled + - StatusFilled + - StatusFullyClaimed + - StatusCancelled + github_com_osmosis-labs_sqs_domain_passthrough.AccountCoinsResult: + properties: + cap_value: + type: string + coin: + $ref: '#/definitions/github_com_cosmos_cosmos-sdk_types.Coin' + type: object + github_com_osmosis-labs_sqs_domain_passthrough.PortfolioAssetsCategoryResult: + properties: + account_coins_result: + description: AccountCoinsResult represents coins only from user balances (contrary + to TotalValueCap). + items: + $ref: '#/definitions/github_com_osmosis-labs_sqs_domain_passthrough.AccountCoinsResult' + type: array + capitalization: + description: |- + Capitalization represents the total value of the assets in the portfolio. + includes capitalization of user balances, value in locks, bonding or unbonding + as well as the concentrated positions. + type: string + is_best_effort: + type: boolean + type: object + github_com_osmosis-labs_sqs_domain_passthrough.PortfolioAssetsResult: + properties: + categories: + additionalProperties: + $ref: '#/definitions/github_com_osmosis-labs_sqs_domain_passthrough.PortfolioAssetsCategoryResult' + type: object + type: object + github_com_osmosis-labs_sqs_orderbook_types.GetActiveOrdersResponse: + properties: + is_best_effort: + type: boolean + orders: + items: + $ref: '#/definitions/github_com_osmosis-labs_sqs_domain_orderbook.LimitOrder' + type: array + type: object sqsdomain.CandidatePool: properties: id: @@ -59,11 +171,40 @@ definitions: type: object type: object type: object + types.Int: + type: object info: contact: {} title: Osmosis Sidecar Query Server Example API version: "1.0" paths: + /passthrough/active-orders: + get: + description: The returned data represents all active orders for all orderbooks + available for the specified address. + parameters: + - description: Osmo wallet address + in: query + name: userOsmoAddress + required: true + type: string + produces: + - application/json + responses: + "200": + description: List of active orders for all available orderboooks for the + given address + schema: + $ref: '#/definitions/github_com_osmosis-labs_sqs_orderbook_types.GetActiveOrdersResponse' + "400": + description: Response error + schema: + $ref: '#/definitions/domain.ResponseError' + "500": + description: Response error + schema: + $ref: '#/definitions/domain.ResponseError' + summary: Returns all active orderbook orders associated with the given address. /passthrough/portfolio-assets/{address}: get: description: The returned data represents the potfolio asset breakdown by category @@ -81,11 +222,11 @@ paths: description: Portfolio assets by-category and capitalization of the entire account value schema: - type: struct + $ref: '#/definitions/github_com_osmosis-labs_sqs_domain_passthrough.PortfolioAssetsResult' "500": description: Response error schema: - type: struct + $ref: '#/definitions/domain.ResponseError' summary: Returns portfolio assets associated with the given address by category. /pools: get: @@ -138,7 +279,7 @@ paths: "200": description: Canonical Orderbook Pool ID for the given base and quote schema: - type: struct + $ref: '#/definitions/domain.CanonicalOrderBooksResult' summary: Get canonical orderbook pool ID for the given base and quote. /pools/canonical-orderbooks: get: diff --git a/domain/errors.go b/domain/errors.go index acc999757..68413abc2 100644 --- a/domain/errors.go +++ b/domain/errors.go @@ -17,6 +17,13 @@ var ( ErrBadParamInput = errors.New("given Param is not valid") ) +var ( + ErrBaseDenomNotValid = errors.New("base denom is empty") + ErrQuoteDenomNotValid = errors.New("quote denom is empty") + ErrPoolIDNotValid = errors.New("pool ID is zero") + ErrContractAddressNotValid = errors.New("contract address is empty") +) + // GetStatusCode returbs status code given error func GetStatusCode(err error) int { if err == nil { diff --git a/domain/mocks/orderbook_grpc_client_mock.go b/domain/mocks/orderbook_grpc_client_mock.go index c91f7d5e4..ba66d489d 100644 --- a/domain/mocks/orderbook_grpc_client_mock.go +++ b/domain/mocks/orderbook_grpc_client_mock.go @@ -12,31 +12,41 @@ var _ orderbookgrpcclientdomain.OrderBookClient = (*OrderbookGRPCClientMock)(nil // OrderbookGRPCClientMock is a mock struct that implements orderbookplugindomain.OrderbookGRPCClient. type OrderbookGRPCClientMock struct { - MockGetOrdersByTickCb func(ctx context.Context, contractAddress string, tick int64) ([]orderbookplugindomain.Order, error) - MockGetActiveOrdersCb func(ctx context.Context, contractAddress string, ownerAddress string) (orderbookdomain.Orders, uint64, error) - MockGetTickUnrealizedCancelsCb func(ctx context.Context, contractAddress string, tickIDs []int64) ([]orderbookgrpcclientdomain.UnrealizedTickCancels, error) - MockQueryTicksCb func(ctx context.Context, contractAddress string, ticks []int64) ([]orderbookdomain.Tick, error) + GetOrdersByTickCb func(ctx context.Context, contractAddress string, tick int64) ([]orderbookplugindomain.Order, error) + GetActiveOrdersCb func(ctx context.Context, contractAddress string, ownerAddress string) (orderbookdomain.Orders, uint64, error) + GetTickUnrealizedCancelsCb func(ctx context.Context, contractAddress string, tickIDs []int64) ([]orderbookgrpcclientdomain.UnrealizedTickCancels, error) + FetchTickUnrealizedCancelsCb func(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]orderbookgrpcclientdomain.UnrealizedTickCancels, error) + MockQueryTicksCb func(ctx context.Context, contractAddress string, ticks []int64) ([]orderbookdomain.Tick, error) + FetchTicksCb func(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]orderbookdomain.Tick, error) } func (o *OrderbookGRPCClientMock) GetOrdersByTick(ctx context.Context, contractAddress string, tick int64) ([]orderbookplugindomain.Order, error) { - if o.MockGetOrdersByTickCb != nil { - return o.MockGetOrdersByTickCb(ctx, contractAddress, tick) + if o.GetOrdersByTickCb != nil { + return o.GetOrdersByTickCb(ctx, contractAddress, tick) } return nil, nil } func (o *OrderbookGRPCClientMock) GetActiveOrders(ctx context.Context, contractAddress string, ownerAddress string) (orderbookdomain.Orders, uint64, error) { - if o.MockGetActiveOrdersCb != nil { - return o.MockGetActiveOrdersCb(ctx, contractAddress, ownerAddress) + if o.GetActiveOrdersCb != nil { + return o.GetActiveOrdersCb(ctx, contractAddress, ownerAddress) } return nil, 0, nil } func (o *OrderbookGRPCClientMock) GetTickUnrealizedCancels(ctx context.Context, contractAddress string, tickIDs []int64) ([]orderbookgrpcclientdomain.UnrealizedTickCancels, error) { - if o.MockGetTickUnrealizedCancelsCb != nil { - return o.MockGetTickUnrealizedCancelsCb(ctx, contractAddress, tickIDs) + if o.GetTickUnrealizedCancelsCb != nil { + return o.GetTickUnrealizedCancelsCb(ctx, contractAddress, tickIDs) + } + + return nil, nil +} + +func (o *OrderbookGRPCClientMock) FetchTickUnrealizedCancels(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]orderbookgrpcclientdomain.UnrealizedTickCancels, error) { + if o.FetchTickUnrealizedCancelsCb != nil { + return o.FetchTickUnrealizedCancelsCb(ctx, chunkSize, contractAddress, tickIDs) } return nil, nil @@ -49,3 +59,11 @@ func (o *OrderbookGRPCClientMock) QueryTicks(ctx context.Context, contractAddres return nil, nil } + +func (o *OrderbookGRPCClientMock) FetchTicks(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]orderbookdomain.Tick, error) { + if o.FetchTicksCb != nil { + return o.FetchTicksCb(ctx, chunkSize, contractAddress, tickIDs) + } + + return nil, nil +} diff --git a/domain/mocks/orderbook_repository_mock.go b/domain/mocks/orderbook_repository_mock.go new file mode 100644 index 000000000..ea087913e --- /dev/null +++ b/domain/mocks/orderbook_repository_mock.go @@ -0,0 +1,48 @@ +package mocks + +import ( + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" +) + +var _ orderbookdomain.OrderBookRepository = &OrderbookRepositoryMock{} + +// OrderbookRepositoryMock is a mock implementation of the OrderBookRepository interface. +type OrderbookRepositoryMock struct { + StoreTicksFunc func(poolID uint64, ticksMap map[int64]orderbookdomain.OrderbookTick) + GetAllTicksFunc func(poolID uint64) (map[int64]orderbookdomain.OrderbookTick, bool) + GetTicksFunc func(poolID uint64, tickIDs []int64) (map[int64]orderbookdomain.OrderbookTick, error) + GetTickByIDFunc func(poolID uint64, tickID int64) (orderbookdomain.OrderbookTick, bool) +} + +// StoreTicks implements OrderBookRepository. +func (m *OrderbookRepositoryMock) StoreTicks(poolID uint64, ticksMap map[int64]orderbookdomain.OrderbookTick) { + if m.StoreTicksFunc != nil { + m.StoreTicksFunc(poolID, ticksMap) + return + } + panic("StoreTicks not implemented") +} + +// GetAllTicks implements OrderBookRepository. +func (m *OrderbookRepositoryMock) GetAllTicks(poolID uint64) (map[int64]orderbookdomain.OrderbookTick, bool) { + if m.GetAllTicksFunc != nil { + return m.GetAllTicksFunc(poolID) + } + panic("GetAllTicks not implemented") +} + +// GetTicks implements OrderBookRepository. +func (m *OrderbookRepositoryMock) GetTicks(poolID uint64, tickIDs []int64) (map[int64]orderbookdomain.OrderbookTick, error) { + if m.GetTicksFunc != nil { + return m.GetTicksFunc(poolID, tickIDs) + } + panic("GetTicks not implemented") +} + +// GetTickByID implements OrderBookRepository. +func (m *OrderbookRepositoryMock) GetTickByID(poolID uint64, tickID int64) (orderbookdomain.OrderbookTick, bool) { + if m.GetTickByIDFunc != nil { + return m.GetTickByIDFunc(poolID, tickID) + } + panic("GetTickByID not implemented") +} diff --git a/domain/mocks/orderbook_usecase_mock.go b/domain/mocks/orderbook_usecase_mock.go new file mode 100644 index 000000000..3928bbe35 --- /dev/null +++ b/domain/mocks/orderbook_usecase_mock.go @@ -0,0 +1,39 @@ +package mocks + +import ( + "context" + + "github.com/osmosis-labs/sqs/domain/mvc" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" + "github.com/osmosis-labs/sqs/sqsdomain" +) + +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) +} + +func (m *OrderbookUsecaseMock) ProcessPool(ctx context.Context, pool sqsdomain.PoolI) error { + if m.ProcessPoolFunc != nil { + return m.ProcessPoolFunc(ctx, pool) + } + panic("unimplemented") +} + +func (m *OrderbookUsecaseMock) GetAllTicks(poolID uint64) (map[int64]orderbookdomain.OrderbookTick, bool) { + if m.GetAllTicksFunc != nil { + return m.GetAllTicksFunc(poolID) + } + panic("unimplemented") +} + +func (m *OrderbookUsecaseMock) GetActiveOrders(ctx context.Context, address string) ([]orderbookdomain.LimitOrder, bool, error) { + if m.GetActiveOrdersFunc != nil { + return m.GetActiveOrdersFunc(ctx, address) + } + panic("unimplemented") +} diff --git a/domain/mocks/pools_usecase_mock.go b/domain/mocks/pools_usecase_mock.go index bc1d4dae0..dee9b8ee6 100644 --- a/domain/mocks/pools_usecase_mock.go +++ b/domain/mocks/pools_usecase_mock.go @@ -19,15 +19,16 @@ import ( var _ mvc.PoolsUsecase = &PoolsUsecaseMock{} type PoolsUsecaseMock struct { - GetAllPoolsFunc func() ([]sqsdomain.PoolI, error) - GetPoolsFunc func(opts ...domain.PoolsOption) ([]sqsdomain.PoolI, error) - StorePoolsFunc func(pools []sqsdomain.PoolI) error - GetRoutesFromCandidatesFunc func(candidateRoutes sqsdomain.CandidateRoutes, tokenInDenom, tokenOutDenom string) ([]route.RouteImpl, error) - GetTickModelMapFunc func(poolIDs []uint64) (map[uint64]*sqsdomain.TickModel, error) - GetPoolFunc func(poolID uint64) (sqsdomain.PoolI, error) - GetPoolSpotPriceFunc func(ctx context.Context, poolID uint64, takerFee osmomath.Dec, quoteAsset, baseAsset string) (osmomath.BigDec, error) - GetCosmWasmPoolConfigFunc func() domain.CosmWasmPoolRouterConfig - CalcExitCFMMPoolFunc func(poolID uint64, exitingShares osmomath.Int) (sdk.Coins, error) + GetAllPoolsFunc func() ([]sqsdomain.PoolI, error) + GetPoolsFunc func(opts ...domain.PoolsOption) ([]sqsdomain.PoolI, error) + StorePoolsFunc func(pools []sqsdomain.PoolI) error + GetRoutesFromCandidatesFunc func(candidateRoutes sqsdomain.CandidateRoutes, tokenInDenom, tokenOutDenom string) ([]route.RouteImpl, error) + GetTickModelMapFunc func(poolIDs []uint64) (map[uint64]*sqsdomain.TickModel, error) + GetPoolFunc func(poolID uint64) (sqsdomain.PoolI, error) + GetPoolSpotPriceFunc func(ctx context.Context, poolID uint64, takerFee osmomath.Dec, quoteAsset, baseAsset string) (osmomath.BigDec, error) + GetCosmWasmPoolConfigFunc func() domain.CosmWasmPoolRouterConfig + CalcExitCFMMPoolFunc func(poolID uint64, exitingShares osmomath.Int) (sdk.Coins, error) + GetAllCanonicalOrderbookPoolIDsFunc func() ([]domain.CanonicalOrderBooksResult, error) Pools []sqsdomain.PoolI TickModelMap map[uint64]*sqsdomain.TickModel @@ -40,6 +41,9 @@ func (pm *PoolsUsecaseMock) IsCanonicalOrderbookPool(poolID uint64) bool { // GetAllCanonicalOrderbookPoolIDs implements mvc.PoolsUsecase. func (pm *PoolsUsecaseMock) GetAllCanonicalOrderbookPoolIDs() ([]domain.CanonicalOrderBooksResult, error) { + if pm.GetAllCanonicalOrderbookPoolIDsFunc != nil { + return pm.GetAllCanonicalOrderbookPoolIDsFunc() + } panic("unimplemented") } diff --git a/domain/orderbook/grpcclient/orderbook_grpc_client.go b/domain/orderbook/grpcclient/orderbook_grpc_client.go index ae3644a79..ba0336c3d 100644 --- a/domain/orderbook/grpcclient/orderbook_grpc_client.go +++ b/domain/orderbook/grpcclient/orderbook_grpc_client.go @@ -2,6 +2,7 @@ package orderbookgrpcclientdomain import ( "context" + "fmt" cosmwasmdomain "github.com/osmosis-labs/sqs/domain/cosmwasm" orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" @@ -21,8 +22,22 @@ type OrderBookClient interface { // GetTickUnrealizedCancels fetches unrealized cancels by tick from the orderbook contract. GetTickUnrealizedCancels(ctx context.Context, contractAddress string, tickIDs []int64) ([]UnrealizedTickCancels, error) + // FetchTickUnrealizedCancels fetches the unrealized cancels for a given tick ID and contract address. + // It returns the unrealized cancels and an error if any. + // Errors if: + // - failed to fetch unrealized cancels + // - mismatch in number of unrealized cancels fetched + FetchTickUnrealizedCancels(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]UnrealizedTickCancels, error) + // QueryTicks fetches ticks by tickIDs from the orderbook contract. QueryTicks(ctx context.Context, contractAddress string, ticks []int64) ([]orderbookdomain.Tick, error) + + // FetchTicksForOrderbook fetches the ticks in chunks of maxQueryTicks at the time for a given tick ID and contract address. + // It returns the ticks and an error if any. + // Errors if: + // - failed to fetch ticks + // - mismatch in number of ticks fetched + FetchTicks(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]orderbookdomain.Tick, error) } // orderbookClientImpl is an implementation of OrderbookCWAPIClient. @@ -39,7 +54,7 @@ func New(wasmClient wasmtypes.QueryClient) *orderbookClientImpl { } } -// GetOrdersByTick implements OrderbookCWAPIClient. +// GetOrdersByTick implements OrderBookClient. func (o *orderbookClientImpl) GetOrdersByTick(ctx context.Context, contractAddress string, tick int64) ([]orderbookplugindomain.Order, error) { ordersByTick := ordersByTick{Tick: tick} @@ -51,7 +66,7 @@ func (o *orderbookClientImpl) GetOrdersByTick(ctx context.Context, contractAddre return orders.Orders, nil } -// GetActiveOrders implements OrderbookCWAPIClient. +// GetActiveOrders implements OrderBookClient. func (o *orderbookClientImpl) GetActiveOrders(ctx context.Context, contractAddress string, ownerAddress string) (orderbookdomain.Orders, uint64, error) { var orders activeOrdersResponse if err := cosmwasmdomain.QueryCosmwasmContract(ctx, o.wasmClient, contractAddress, activeOrdersRequest{OrdersByOwner: ordersByOwner{Owner: ownerAddress}}, &orders); err != nil { @@ -61,7 +76,7 @@ func (o *orderbookClientImpl) GetActiveOrders(ctx context.Context, contractAddre return orders.Orders, orders.Count, nil } -// GetTickUnrealizedCancels implements OrderbookCWAPIClient. +// GetTickUnrealizedCancels implements OrderBookClient. func (o *orderbookClientImpl) GetTickUnrealizedCancels(ctx context.Context, contractAddress string, tickIDs []int64) ([]UnrealizedTickCancels, error) { var unrealizedCancels unrealizedCancelsResponse if err := cosmwasmdomain.QueryCosmwasmContract(ctx, o.wasmClient, contractAddress, unrealizedCancelsByTickIdRequest{UnrealizedCancels: unrealizedCancelsRequestPayload{TickIds: tickIDs}}, &unrealizedCancels); err != nil { @@ -70,6 +85,33 @@ func (o *orderbookClientImpl) GetTickUnrealizedCancels(ctx context.Context, cont return unrealizedCancels.Ticks, nil } +// FetchTickUnrealizedCancels implements OrderBookClient. +func (o *orderbookClientImpl) FetchTickUnrealizedCancels(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]UnrealizedTickCancels, error) { + allUnrealizedCancels := make([]UnrealizedTickCancels, 0, len(tickIDs)) + + for i := 0; i < len(tickIDs); i += chunkSize { + end := i + chunkSize + if end > len(tickIDs) { + end = len(tickIDs) + } + + currentTickIDs := tickIDs[i:end] + + unrealizedCancels, err := o.GetTickUnrealizedCancels(ctx, contractAddress, currentTickIDs) + if err != nil { + return nil, fmt.Errorf("failed to fetch unrealized cancels for ticks %v: %w", currentTickIDs, err) + } + + allUnrealizedCancels = append(allUnrealizedCancels, unrealizedCancels...) + } + + if len(allUnrealizedCancels) != len(tickIDs) { + return nil, fmt.Errorf("mismatch in number of unrealized cancels fetched: expected %d, got %d", len(tickIDs), len(allUnrealizedCancels)) + } + + return allUnrealizedCancels, nil +} + // QueryTicks implements OrderBookClient. func (o *orderbookClientImpl) QueryTicks(ctx context.Context, contractAddress string, ticks []int64) ([]orderbookdomain.Tick, error) { var orderbookTicks queryTicksResponse @@ -78,3 +120,30 @@ func (o *orderbookClientImpl) QueryTicks(ctx context.Context, contractAddress st } return orderbookTicks.Ticks, nil } + +// FetchTicks implements OrderBookClient. +func (o *orderbookClientImpl) FetchTicks(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]orderbookdomain.Tick, error) { + finalTickStates := make([]orderbookdomain.Tick, 0, len(tickIDs)) + + for i := 0; i < len(tickIDs); i += chunkSize { + end := i + chunkSize + if end > len(tickIDs) { + end = len(tickIDs) + } + + currentTickIDs := tickIDs[i:end] + + tickStates, err := o.QueryTicks(ctx, contractAddress, currentTickIDs) + if err != nil { + return nil, fmt.Errorf("failed to fetch ticks for pool %s: %w", contractAddress, err) + } + + finalTickStates = append(finalTickStates, tickStates...) + } + + if len(finalTickStates) != len(tickIDs) { + return nil, fmt.Errorf("mismatch in number of ticks fetched: expected %d, got %d", len(tickIDs), len(finalTickStates)) + } + + return finalTickStates, nil +} diff --git a/domain/orderbook/order.go b/domain/orderbook/order.go index 653f6d20b..d5c89df81 100644 --- a/domain/orderbook/order.go +++ b/domain/orderbook/order.go @@ -3,6 +3,8 @@ package orderbookdomain import ( "fmt" "strconv" + + "github.com/osmosis-labs/osmosis/osmomath" ) // OrderStatus represents the status of an order. @@ -71,23 +73,23 @@ type Asset struct { } type LimitOrder struct { - TickId int64 `json:"tick_id"` - OrderId int64 `json:"order_id"` - OrderDirection string `json:"order_direction"` - Owner string `json:"owner"` - Quantity int64 `json:"quantity"` - Etas string `json:"etas"` - ClaimBounty string `json:"claim_bounty"` - PlacedQuantity int64 `json:"placed_quantity"` - PlacedAt int64 `json:"placed_at"` - Price string `json:"price"` - PercentClaimed string `json:"percentClaimed"` - TotalFilled int64 `json:"totalFilled"` - PercentFilled string `json:"percentFilled"` - OrderbookAddress string `json:"orderbookAddress"` - Status OrderStatus `json:"status"` - Output string `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 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"` } diff --git a/domain/pools.go b/domain/pools.go index 827b204b6..2378196b1 100644 --- a/domain/pools.go +++ b/domain/pools.go @@ -67,6 +67,23 @@ type CanonicalOrderBooksResult struct { ContractAddress string `json:"contract_address"` } +// Validate validates the canonical orderbook result. +func (c CanonicalOrderBooksResult) Validate() error { + if c.Base == "" { + return ErrBaseDenomNotValid + } + if c.Quote == "" { + return ErrQuoteDenomNotValid + } + if c.PoolID == 0 { + return ErrPoolIDNotValid + } + if c.ContractAddress == "" { + return ErrContractAddressNotValid + } + return nil +} + type PoolsOptions struct { MinPoolLiquidityCap uint64 PoolIDFilter []uint64 diff --git a/orderbook/types/errors.go b/orderbook/types/errors.go new file mode 100644 index 000000000..c57f6ce6c --- /dev/null +++ b/orderbook/types/errors.go @@ -0,0 +1,220 @@ +package types + +import ( + "fmt" + + "github.com/osmosis-labs/osmosis/osmomath" +) + +// TickForOrderbookNotFoundError represents an error when a tick is not found for a given orderbook. +type TickForOrderbookNotFoundError struct { + OrderbookAddress string + TickID int64 +} + +// Error implements the error interface. +func (e TickForOrderbookNotFoundError) Error() string { + return fmt.Sprintf("tick not found %s, %d", e.OrderbookAddress, e.TickID) +} + +// ParsingQuantityError represents an error that occurs while parsing the quantity field. +type ParsingQuantityError struct { + Quantity string + Err error +} + +// Error implements the error interface. +func (e ParsingQuantityError) Error() string { + return fmt.Sprintf("error parsing quantity %s: %v", e.Quantity, e.Err) +} + +// ParsingPlacedQuantityError represents an error that occurs while parsing the placed quantity field. +type ParsingPlacedQuantityError struct { + PlacedQuantity string + Err error +} + +// Error implements the error interface. +func (e ParsingPlacedQuantityError) Error() string { + return fmt.Sprintf("error parsing placed quantity %s: %v", e.PlacedQuantity, e.Err) +} + +// InvalidPlacedQuantityError represents an error when the placed quantity is invalid. +type InvalidPlacedQuantityError struct { + PlacedQuantity osmomath.Dec +} + +// Error implements the error interface. +func (e InvalidPlacedQuantityError) Error() string { + return fmt.Sprintf("placed quantity is 0 or negative: %d", e.PlacedQuantity) +} + +// GettingSpotPriceScalingFactorError represents an error that occurs while getting the spot price scaling factor. +type GettingSpotPriceScalingFactorError struct { + BaseDenom string + QuoteDenom string + Err error +} + +// Error implements the error interface. +func (e GettingSpotPriceScalingFactorError) Error() string { + return fmt.Sprintf("error getting spot price scaling factor for base denom %s and quote denom %s: %v", e.BaseDenom, e.QuoteDenom, e.Err) +} + +// ParsingTickValuesError represents an error that occurs while parsing the tick values. +type ParsingTickValuesError struct { + Field string + Err error +} + +// Error implements the error interface. +func (e ParsingTickValuesError) Error() string { + return fmt.Sprintf("error parsing tick values for field %s: %v", e.Field, e.Err) +} + +// ParsingUnrealizedCancelsError represents an error that occurs while parsing the unrealized cancels. +type ParsingUnrealizedCancelsError struct { + Field string + Err error +} + +// Error implements the error interface. +func (e ParsingUnrealizedCancelsError) Error() string { + return fmt.Sprintf("error parsing unrealized cancels for field %s: %v", e.Field, e.Err) +} + +// ParsingEtasError represents an error that occurs while parsing the ETAs field. +type ParsingEtasError struct { + Etas string + Err error +} + +// Error implements the error interface. +func (e ParsingEtasError) Error() string { + return fmt.Sprintf("error parsing etas %s: %v", e.Etas, e.Err) +} + +// MappingOrderStatusError represents an error that occurs while mapping the order status. +type MappingOrderStatusError struct { + Err error +} + +// Error implements the error interface. +func (e MappingOrderStatusError) Error() string { + return fmt.Sprintf("error mapping order status: %v", e.Err) +} + +// ConvertingTickToPriceError represents an error that occurs while converting a tick to a price. +type ConvertingTickToPriceError struct { + TickID int64 + Err error +} + +// Error implements the error interface. +func (e ConvertingTickToPriceError) Error() string { + return fmt.Sprintf("error converting tick ID %d to price: %v", e.TickID, e.Err) +} + +// ParsingPlacedAtError represents an error that occurs while parsing the placed_at field. +type ParsingPlacedAtError struct { + PlacedAt string + Err error +} + +// Error implements the error interface. +func (e ParsingPlacedAtError) Error() string { + return fmt.Sprintf("error parsing placed_at %s: %v", e.PlacedAt, e.Err) +} + +// PoolNilError represents an error when the pool is nil. +type PoolNilError struct{} + +func (e PoolNilError) Error() string { + return "pool is nil when processing order book" +} + +// CosmWasmPoolModelNilError represents an error when the CosmWasmPoolModel is nil. +type CosmWasmPoolModelNilError struct{} + +func (e CosmWasmPoolModelNilError) Error() string { + return "cw pool model is nil when processing order book" +} + +// NotAnOrderbookPoolError represents an error when the pool is not an orderbook pool. +type NotAnOrderbookPoolError struct { + PoolID uint64 +} + +func (e NotAnOrderbookPoolError) Error() string { + return fmt.Sprintf("pool is not an orderbook pool %d", e.PoolID) +} + +// FailedToCastPoolModelError represents an error when the pool model cannot be cast to a CosmWasmPool. +type FailedToCastPoolModelError struct{} + +func (e FailedToCastPoolModelError) Error() string { + return "failed to cast pool model to CosmWasmPool" +} + +// FetchTicksError represents an error when fetching ticks fails. +type FetchTicksError struct { + ContractAddress string + Err error +} + +func (e FetchTicksError) Error() string { + return fmt.Sprintf("failed to fetch ticks for pool %s: %v", e.ContractAddress, e.Err) +} + +// FetchUnrealizedCancelsError represents an error when fetching unrealized cancels fails. +type FetchUnrealizedCancelsError struct { + ContractAddress string + Err error +} + +func (e FetchUnrealizedCancelsError) Error() string { + return fmt.Sprintf("failed to fetch unrealized cancels for pool %s: %v", e.ContractAddress, e.Err) +} + +// TickIDMismatchError represents an error when there is a mismatch between tick IDs. +type TickIDMismatchError struct { + ExpectedID int64 + ActualID int64 +} + +func (e TickIDMismatchError) Error() string { + return fmt.Sprintf("tick id mismatch when fetching tick states %d %d", e.ExpectedID, e.ActualID) +} + +// FailedGetAllCanonicalOrderbookPoolIDsError represents an error when failing to get all canonical orderbook pool IDs. +type FailedGetAllCanonicalOrderbookPoolIDsError struct { + Err error +} + +// Error implements the error interface. +func (e FailedGetAllCanonicalOrderbookPoolIDsError) Error() string { + return fmt.Sprintf("failed to get all canonical orderbook pool IDs: %v", e.Err) +} + +// FailedToGetActiveOrdersError is returned when the retrieval of active orders fails. +type FailedToGetActiveOrdersError struct { + ContractAddress string + OwnerAddress string + Err error +} + +// Error implements the error interface. +func (e FailedToGetActiveOrdersError) Error() string { + return fmt.Sprintf("failed to get active orders for contract: %s and owner: %s: %v", e.ContractAddress, e.OwnerAddress, e.Err) +} + +// FailedToGetMetadataError is returned when getting token metadata fails. +type FailedToGetMetadataError struct { + TokenDenom string + Err error +} + +// Error implements the error interface. +func (e FailedToGetMetadataError) Error() string { + return fmt.Sprintf("failed to get metadata for token denom: %s: %v", e.TokenDenom, e.Err) +} diff --git a/orderbook/types/get_orders_request.go b/orderbook/types/get_orders_request.go index fe9bf097a..cd56993a3 100644 --- a/orderbook/types/get_orders_request.go +++ b/orderbook/types/get_orders_request.go @@ -1,47 +1,40 @@ package types import ( + "fmt" "sort" - "strconv" orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/labstack/echo/v4" ) +var ( + // ErrUserOsmoAddressInvalid is generic error returned when the user address is invalid. + ErrUserOsmoAddressInvalid = fmt.Errorf("userOsmoAddress is not valid osmo address") + + ErrInternalError = fmt.Errorf("internal error") +) + // GetActiveOrdersRequest represents get orders request for the /pools/all-orders endpoint. type GetActiveOrdersRequest struct { UserOsmoAddress string - Limit int - Cursor int } // UnmarshalHTTPRequest unmarshals the HTTP request to GetActiveOrdersRequest. func (r *GetActiveOrdersRequest) UnmarshalHTTPRequest(c echo.Context) error { r.UserOsmoAddress = c.QueryParam("userOsmoAddress") - - if limit := c.QueryParam("limit"); limit != "" { - i, err := strconv.Atoi(limit) - if err != nil { - return err - } - r.Limit = i - } - - if cursor := c.QueryParam("cursor"); cursor != "" { - i, err := strconv.Atoi(cursor) - if err != nil { - return err - } - r.Cursor = i - } - return nil } // Validate validates the GetActiveOrdersRequest. -// TODO: implement validation rules func (r *GetActiveOrdersRequest) Validate() error { + _, err := sdk.AccAddressFromBech32(r.UserOsmoAddress) + if err != nil { + return ErrUserOsmoAddressInvalid + } return nil } diff --git a/orderbook/usecase/export_test.go b/orderbook/usecase/export_test.go new file mode 100644 index 000000000..f6c419b37 --- /dev/null +++ b/orderbook/usecase/export_test.go @@ -0,0 +1,23 @@ +package orderbookusecase + +import ( + "context" + "github.com/osmosis-labs/sqs/domain" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" +) + +// CreateFormattedLimitOrder is an alias of createFormattedLimitOrder for testing purposes +func (o *OrderbookUseCaseImpl) CreateFormattedLimitOrder( + poolID uint64, + order orderbookdomain.Order, + quoteAsset orderbookdomain.Asset, + baseAsset orderbookdomain.Asset, + orderbookAddress string, +) (orderbookdomain.LimitOrder, error) { + return o.createFormattedLimitOrder(poolID, order, quoteAsset, baseAsset, orderbookAddress) +} + +// ProcessOrderBookActiveOrders is an alias of processOrderBookActiveOrders for testing purposes +func (o *OrderbookUseCaseImpl) ProcessOrderBookActiveOrders(ctx context.Context, orderBook domain.CanonicalOrderBooksResult, ownerAddress string) ([]orderbookdomain.LimitOrder, bool, error) { + return o.processOrderBookActiveOrders(ctx, orderBook, ownerAddress) +} diff --git a/orderbook/usecase/orderbook_usecase.go b/orderbook/usecase/orderbook_usecase.go index 2635ae27d..11fcee160 100644 --- a/orderbook/usecase/orderbook_usecase.go +++ b/orderbook/usecase/orderbook_usecase.go @@ -3,7 +3,6 @@ package orderbookusecase import ( "context" "fmt" - "math" "strconv" "time" @@ -15,13 +14,14 @@ import ( orderbookgrpcclientdomain "github.com/osmosis-labs/sqs/domain/orderbook/grpcclient" "github.com/osmosis-labs/sqs/log" "github.com/osmosis-labs/sqs/orderbook/telemetry" + "github.com/osmosis-labs/sqs/orderbook/types" "github.com/osmosis-labs/sqs/sqsdomain" "go.uber.org/zap" clmath "github.com/osmosis-labs/osmosis/v25/x/concentrated-liquidity/math" ) -type orderbookUseCaseImpl struct { +type OrderbookUseCaseImpl struct { orderbookRepository orderbookdomain.OrderBookRepository orderBookClient orderbookgrpcclientdomain.OrderBookClient poolsUsecease mvc.PoolsUsecase @@ -29,7 +29,7 @@ type orderbookUseCaseImpl struct { logger log.Logger } -var _ mvc.OrderBookUsecase = &orderbookUseCaseImpl{} +var _ mvc.OrderBookUsecase = &OrderbookUseCaseImpl{} const ( // Max number of ticks to query at a time @@ -45,8 +45,8 @@ func New( poolsUsecease mvc.PoolsUsecase, tokensUsecease mvc.TokensUsecase, logger log.Logger, -) mvc.OrderBookUsecase { - return &orderbookUseCaseImpl{ +) *OrderbookUseCaseImpl { + return &OrderbookUseCaseImpl{ orderbookRepository: orderbookRepository, orderBookClient: orderBookClient, poolsUsecease: poolsUsecease, @@ -55,21 +55,25 @@ func New( } } -// GetTicks implements mvc.OrderBookUsecase. -func (o *orderbookUseCaseImpl) GetAllTicks(poolID uint64) (map[int64]orderbookdomain.OrderbookTick, bool) { +// GetAllTicks implements mvc.OrderBookUsecase. +func (o *OrderbookUseCaseImpl) GetAllTicks(poolID uint64) (map[int64]orderbookdomain.OrderbookTick, bool) { return o.orderbookRepository.GetAllTicks(poolID) } -// StoreTicks implements mvc.OrderBookUsecase. -func (o *orderbookUseCaseImpl) ProcessPool(ctx context.Context, pool sqsdomain.PoolI) error { +// ProcessPool implements mvc.OrderBookUsecase. +func (o *OrderbookUseCaseImpl) ProcessPool(ctx context.Context, pool sqsdomain.PoolI) error { + if pool == nil { + return types.PoolNilError{} + } + cosmWasmPoolModel := pool.GetSQSPoolModel().CosmWasmPoolModel if cosmWasmPoolModel == nil { - return fmt.Errorf("cw pool model is nil when processing order book") + return types.CosmWasmPoolModelNilError{} } poolID := pool.GetId() if !cosmWasmPoolModel.IsOrderbook() { - return fmt.Errorf("pool is not an orderbook pool %d", poolID) + return types.NotAnOrderbookPoolError{PoolID: poolID} } if cosmWasmPoolModel.Data.Orderbook == nil { @@ -84,7 +88,7 @@ func (o *orderbookUseCaseImpl) ProcessPool(ctx context.Context, pool sqsdomain.P cwModel, ok := pool.GetUnderlyingPool().(*cwpoolmodel.CosmWasmPool) if !ok { - return fmt.Errorf("failed to cast pool model to CosmWasmPool") + return types.FailedToCastPoolModelError{} } // Get tick IDs @@ -94,15 +98,15 @@ func (o *orderbookUseCaseImpl) ProcessPool(ctx context.Context, pool sqsdomain.P } // Fetch tick states - tickStates, err := o.fetchTicksForOrderbook(ctx, cwModel.ContractAddress, tickIDs) + tickStates, err := o.orderBookClient.FetchTicks(ctx, maxQueryTicks, cwModel.ContractAddress, tickIDs) if err != nil { - return fmt.Errorf("failed to fetch ticks for pool %s: %w", cwModel.ContractAddress, err) + return types.FetchTicksError{ContractAddress: cwModel.ContractAddress, Err: err} } // Fetch unrealized cancels - unrealizedCancels, err := o.fetchTickUnrealizedCancels(ctx, cwModel.ContractAddress, tickIDs) + unrealizedCancels, err := o.orderBookClient.FetchTickUnrealizedCancels(ctx, maxQueryTicksCancels, cwModel.ContractAddress, tickIDs) if err != nil { - return fmt.Errorf("failed to fetch unrealized cancels for pool %s: %w", cwModel.ContractAddress, err) + return types.FetchUnrealizedCancelsError{ContractAddress: cwModel.ContractAddress, Err: err} } tickDataMap := make(map[int64]orderbookdomain.OrderbookTick, len(ticks)) @@ -111,12 +115,12 @@ func (o *orderbookUseCaseImpl) ProcessPool(ctx context.Context, pool sqsdomain.P // Validate the tick IDs match between the tick and the unrealized cancel if unrealizedCancel.TickID != tick.TickId { - return fmt.Errorf("tick id mismatch when fetching unrealized ticks %d %d", unrealizedCancel.TickID, tick.TickId) + return types.TickIDMismatchError{ExpectedID: tick.TickId, ActualID: unrealizedCancel.TickID} } tickState := tickStates[i] if tickState.TickID != tick.TickId { - return fmt.Errorf("tick id mismatch when fetching tick states %d %d", tickState.TickID, tick.TickId) + return types.TickIDMismatchError{ExpectedID: tick.TickId, ActualID: tickState.TickID} } // Update tick map for the pool @@ -134,10 +138,10 @@ func (o *orderbookUseCaseImpl) ProcessPool(ctx context.Context, pool sqsdomain.P } // GetActiveOrders implements mvc.OrderBookUsecase. -func (o *orderbookUseCaseImpl) GetActiveOrders(ctx context.Context, address string) ([]orderbookdomain.LimitOrder, bool, error) { +func (o *OrderbookUseCaseImpl) GetActiveOrders(ctx context.Context, address string) ([]orderbookdomain.LimitOrder, bool, error) { orderbooks, err := o.poolsUsecease.GetAllCanonicalOrderbookPoolIDs() if err != nil { - return nil, false, fmt.Errorf("failed to get all canonical orderbook pool IDs: %w", err) + return nil, false, types.FailedGetAllCanonicalOrderbookPoolIDsError{Err: err} } type orderbookResult struct { @@ -148,7 +152,6 @@ func (o *orderbookUseCaseImpl) GetActiveOrders(ctx context.Context, address stri } results := make(chan orderbookResult, len(orderbooks)) - defer close(results) // Process orderbooks concurrently for _, orderbook := range orderbooks { @@ -174,7 +177,6 @@ func (o *orderbookUseCaseImpl) GetActiveOrders(ctx context.Context, address stri if result.err != nil { telemetry.ProcessingOrderbookActiveOrdersErrorCounter.Inc() o.logger.Error(telemetry.ProcessingOrderbookActiveOrdersErrorMetricName, zap.Any("orderbook_id", result.orderbookID), zap.Any("err", result.err)) - return nil, false, result.err } isBestEffort = isBestEffort || result.isBestEffort @@ -197,10 +199,18 @@ func (o *orderbookUseCaseImpl) GetActiveOrders(ctx context.Context, address stri // // For every order, if an error occurs processing the order, it is skipped rather than failing the entire process. // This is a best-effort process. -func (o *orderbookUseCaseImpl) processOrderBookActiveOrders(ctx context.Context, orderBook domain.CanonicalOrderBooksResult, ownerAddress string) ([]orderbookdomain.LimitOrder, bool, error) { +func (o *OrderbookUseCaseImpl) processOrderBookActiveOrders(ctx context.Context, orderBook domain.CanonicalOrderBooksResult, ownerAddress string) ([]orderbookdomain.LimitOrder, bool, error) { + if err := orderBook.Validate(); err != nil { + return nil, false, err + } + orders, count, err := o.orderBookClient.GetActiveOrders(ctx, orderBook.ContractAddress, ownerAddress) if err != nil { - return nil, false, err + return nil, false, types.FailedToGetActiveOrdersError{ + ContractAddress: orderBook.ContractAddress, + OwnerAddress: ownerAddress, + Err: err, + } } // There are orders to process for given orderbook @@ -210,12 +220,18 @@ func (o *orderbookUseCaseImpl) processOrderBookActiveOrders(ctx context.Context, quoteToken, err := o.tokensUsecease.GetMetadataByChainDenom(orderBook.Quote) if err != nil { - return nil, false, err + return nil, false, types.FailedToGetMetadataError{ + TokenDenom: orderBook.Quote, + Err: err, + } } baseToken, err := o.tokensUsecease.GetMetadataByChainDenom(orderBook.Base) if err != nil { - return nil, false, err + return nil, false, types.FailedToGetMetadataError{ + TokenDenom: orderBook.Base, + Err: err, + } } // Create a slice to store the results @@ -255,8 +271,12 @@ func (o *orderbookUseCaseImpl) processOrderBookActiveOrders(ctx context.Context, return results, isBestEffort, nil } +// ZeroDec is a zero decimal value. +// It is defined in a global space to avoid creating a new instance every time. +var zeroDec = osmomath.ZeroDec() + // createFormattedLimitOrder creates a limit order from the orderbook order. -func (o *orderbookUseCaseImpl) createFormattedLimitOrder( +func (o *OrderbookUseCaseImpl) createFormattedLimitOrder( poolID uint64, order orderbookdomain.Order, quoteAsset orderbookdomain.Asset, @@ -266,102 +286,126 @@ func (o *orderbookUseCaseImpl) createFormattedLimitOrder( tickForOrder, ok := o.orderbookRepository.GetTickByID(poolID, order.TickId) if !ok { telemetry.GetTickByIDNotFoundCounter.Inc() - return orderbookdomain.LimitOrder{}, fmt.Errorf("tick not found %s, %d", orderbookAddress, order.TickId) + return orderbookdomain.LimitOrder{}, types.TickForOrderbookNotFoundError{ + OrderbookAddress: orderbookAddress, + TickID: order.TickId, + } } tickState := tickForOrder.TickState unrealizedCancels := tickForOrder.UnrealizedCancels - // Parse quantity as int64 - quantity, err := strconv.ParseInt(order.Quantity, 10, 64) + quantity, err := osmomath.NewDecFromStr(order.Quantity) if err != nil { - return orderbookdomain.LimitOrder{}, fmt.Errorf("error parsing quantity: %w", err) + return orderbookdomain.LimitOrder{}, types.ParsingQuantityError{ + Quantity: order.Quantity, + Err: err, + } } - // Convert quantity to decimal for the calculations - quantityDec := osmomath.NewDec(quantity) - - placedQuantity, err := strconv.ParseInt(order.PlacedQuantity, 10, 64) + placedQuantity, err := osmomath.NewDecFromStr(order.PlacedQuantity) if err != nil { - return orderbookdomain.LimitOrder{}, fmt.Errorf("error parsing placed quantity: %w", err) + return orderbookdomain.LimitOrder{}, types.ParsingPlacedQuantityError{ + PlacedQuantity: order.PlacedQuantity, + Err: err, + } } - placedQuantityDec, err := osmomath.NewDecFromStr(order.PlacedQuantity) - if err != nil { - return orderbookdomain.LimitOrder{}, fmt.Errorf("error parsing placed quantity: %w", err) + if placedQuantity.Equal(zeroDec) || placedQuantity.LT(zeroDec) { + return orderbookdomain.LimitOrder{}, types.InvalidPlacedQuantityError{PlacedQuantity: placedQuantity} } // Calculate percent claimed - percentClaimed := placedQuantityDec.Sub(quantityDec).Quo(placedQuantityDec) + percentClaimed := placedQuantity.Sub(quantity).Quo(placedQuantity) // Calculate normalization factor for price normalizationFactor, err := o.tokensUsecease.GetSpotPriceScalingFactorByDenom(baseAsset.Symbol, quoteAsset.Symbol) if err != nil { - return orderbookdomain.LimitOrder{}, fmt.Errorf("error getting spot price scaling factor: %w", err) + return orderbookdomain.LimitOrder{}, types.GettingSpotPriceScalingFactorError{ + BaseDenom: baseAsset.Symbol, + QuoteDenom: quoteAsset.Symbol, + Err: err, + } } // Determine tick values and unrealized cancels based on order direction - var tickEtas, tickUnrealizedCancelled int64 + var tickEtas, tickUnrealizedCancelled osmomath.Dec if order.OrderDirection == "bid" { - tickEtas, err = strconv.ParseInt(tickState.BidValues.EffectiveTotalAmountSwapped, 10, 64) + tickEtas, err = osmomath.NewDecFromStr(tickState.BidValues.EffectiveTotalAmountSwapped) if err != nil { - return orderbookdomain.LimitOrder{}, fmt.Errorf("error parsing bid effective total amount swapped: %w", err) + return orderbookdomain.LimitOrder{}, types.ParsingTickValuesError{ + Field: "EffectiveTotalAmountSwapped (bid)", + Err: err, + } } - tickUnrealizedCancelled, err = strconv.ParseInt(unrealizedCancels.BidUnrealizedCancels.String(), 10, 64) - if err != nil { - return orderbookdomain.LimitOrder{}, fmt.Errorf("error parsing bid unrealized cancels: %w", err) + if unrealizedCancels.BidUnrealizedCancels.IsNil() { + return orderbookdomain.LimitOrder{}, types.ParsingUnrealizedCancelsError{ + Field: "BidUnrealizedCancels", + Err: fmt.Errorf("nil value for bid unrealized cancels"), + } } + + tickUnrealizedCancelled = osmomath.NewDecFromInt(unrealizedCancels.BidUnrealizedCancels) } else { - tickEtas, err = strconv.ParseInt(tickState.AskValues.EffectiveTotalAmountSwapped, 10, 64) + tickEtas, err = osmomath.NewDecFromStr(tickState.AskValues.EffectiveTotalAmountSwapped) if err != nil { - return orderbookdomain.LimitOrder{}, fmt.Errorf("error parsing ask effective total amount swapped: %w", err) + return orderbookdomain.LimitOrder{}, types.ParsingTickValuesError{ + Field: "EffectiveTotalAmountSwapped (ask)", + Err: err, + } } - tickUnrealizedCancelled, err = strconv.ParseInt(unrealizedCancels.AskUnrealizedCancels.String(), 10, 64) - if err != nil { - return orderbookdomain.LimitOrder{}, fmt.Errorf("error parsing ask unrealized cancels: %w", err) + if unrealizedCancels.AskUnrealizedCancels.IsNil() { + return orderbookdomain.LimitOrder{}, types.ParsingUnrealizedCancelsError{ + Field: "AskUnrealizedCancels", + Err: fmt.Errorf("nil value for ask unrealized cancels"), + } } + + tickUnrealizedCancelled = osmomath.NewDecFromInt(unrealizedCancels.AskUnrealizedCancels) } // Calculate total ETAs and total filled - - etas, err := strconv.ParseInt(order.Etas, 10, 64) + etas, err := osmomath.NewDecFromStr(order.Etas) if err != nil { - return orderbookdomain.LimitOrder{}, fmt.Errorf("error parsing etas: %w", err) + return orderbookdomain.LimitOrder{}, types.ParsingEtasError{ + Etas: order.Etas, + Err: err, + } } - tickTotalEtas := tickEtas + tickUnrealizedCancelled + tickTotalEtas := tickEtas.Add(tickUnrealizedCancelled) - totalFilled := int64(math.Max( - float64(tickTotalEtas-(etas-(placedQuantity-quantity))), - 0, - )) + totalFilled := osmomath.MaxDec( + tickTotalEtas.Sub(etas.Sub(placedQuantity.Sub(quantity))), + osmomath.ZeroDec(), + ) // Calculate percent filled using - percentFilled, err := osmomath.NewDecFromStr(strconv.FormatFloat(math.Min(float64(totalFilled)/float64(placedQuantity), 1), 'f', -1, 64)) - if err != nil { - return orderbookdomain.LimitOrder{}, fmt.Errorf("error calculating percent filled: %w", err) - } + percentFilled := osmomath.MinDec( + totalFilled.Quo(placedQuantity), + osmomath.OneDec(), + ) // Determine order status based on percent filled status, err := order.Status(percentFilled.MustFloat64()) if err != nil { - return orderbookdomain.LimitOrder{}, fmt.Errorf("mapping order status: %w", err) + return orderbookdomain.LimitOrder{}, types.MappingOrderStatusError{Err: err} } // Calculate price based on tick ID price, err := clmath.TickToPrice(order.TickId) if err != nil { - return orderbookdomain.LimitOrder{}, fmt.Errorf("converting tick to price: %w", err) + return orderbookdomain.LimitOrder{}, types.ConvertingTickToPriceError{TickID: order.TickId, Err: err} } // Calculate output based on order direction var output osmomath.Dec if order.OrderDirection == "bid" { - output = placedQuantityDec.Quo(price.Dec()) + output = placedQuantity.Quo(price.Dec()) } else { - output = placedQuantityDec.Mul(price.Dec()) + output = placedQuantity.Mul(price.Dec()) } // Calculate normalized price @@ -370,7 +414,10 @@ func (o *orderbookUseCaseImpl) createFormattedLimitOrder( // Convert placed_at to a nano second timestamp placedAt, err := strconv.ParseInt(order.PlacedAt, 10, 64) if err != nil { - return orderbookdomain.LimitOrder{}, fmt.Errorf("error parsing placed_at: %w", err) + return orderbookdomain.LimitOrder{}, types.ParsingPlacedAtError{ + PlacedAt: order.PlacedAt, + Err: err, + } } placedAt = time.Unix(0, placedAt).Unix() @@ -384,77 +431,15 @@ func (o *orderbookUseCaseImpl) createFormattedLimitOrder( Etas: order.Etas, ClaimBounty: order.ClaimBounty, PlacedQuantity: placedQuantity, - PercentClaimed: percentClaimed.String(), + PercentClaimed: percentClaimed, TotalFilled: totalFilled, - PercentFilled: percentFilled.String(), + PercentFilled: percentFilled, OrderbookAddress: orderbookAddress, - Price: normalizedPrice.String(), + Price: normalizedPrice, Status: status, - Output: output.String(), + Output: output, QuoteAsset: quoteAsset, BaseAsset: baseAsset, PlacedAt: placedAt, }, nil } - -// fetchTicksForOrderbook fetches the ticks for a given tick ID and contract address. -// It returns the ticks and an error if any. -// Errors if: -// - failed to fetch ticks -// - mismatch in number of ticks fetched -func (o *orderbookUseCaseImpl) fetchTicksForOrderbook(ctx context.Context, contractAddress string, tickIDs []int64) ([]orderbookdomain.Tick, error) { - finalTickStates := make([]orderbookdomain.Tick, 0, len(tickIDs)) - - for i := 0; i < len(tickIDs); i += maxQueryTicks { - end := i + maxQueryTicks - if end > len(tickIDs) { - end = len(tickIDs) - } - - currentTickIDs := tickIDs[i:end] - - tickStates, err := o.orderBookClient.QueryTicks(ctx, contractAddress, currentTickIDs) - if err != nil { - return nil, fmt.Errorf("failed to fetch ticks for pool %s: %w", contractAddress, err) - } - - finalTickStates = append(finalTickStates, tickStates...) - } - - if len(finalTickStates) != len(tickIDs) { - return nil, fmt.Errorf("mismatch in number of ticks fetched: expected %d, got %d", len(tickIDs), len(finalTickStates)) - } - - return finalTickStates, nil -} - -// fetchTickUnrealizedCancels fetches the unrealized cancels for a given tick ID and contract address. -// It returns the unrealized cancels and an error if any. -// Errors if: -// - failed to fetch unrealized cancels -// - mismatch in number of unrealized cancels fetched -func (o *orderbookUseCaseImpl) fetchTickUnrealizedCancels(ctx context.Context, contractAddress string, tickIDs []int64) ([]orderbookgrpcclientdomain.UnrealizedTickCancels, error) { - allUnrealizedCancels := make([]orderbookgrpcclientdomain.UnrealizedTickCancels, 0, len(tickIDs)) - - for i := 0; i < len(tickIDs); i += maxQueryTicksCancels { - end := i + maxQueryTicksCancels - if end > len(tickIDs) { - end = len(tickIDs) - } - - currentTickIDs := tickIDs[i:end] - - unrealizedCancels, err := o.orderBookClient.GetTickUnrealizedCancels(ctx, contractAddress, currentTickIDs) - if err != nil { - return nil, fmt.Errorf("failed to fetch unrealized cancels for ticks %v: %w", currentTickIDs, err) - } - - allUnrealizedCancels = append(allUnrealizedCancels, unrealizedCancels...) - } - - if len(allUnrealizedCancels) != len(tickIDs) { - return nil, fmt.Errorf("mismatch in number of unrealized cancels fetched: expected %d, got %d", len(tickIDs), len(allUnrealizedCancels)) - } - - return allUnrealizedCancels, nil -} diff --git a/orderbook/usecase/orderbook_usecase_test.go b/orderbook/usecase/orderbook_usecase_test.go new file mode 100644 index 000000000..40b4b76f9 --- /dev/null +++ b/orderbook/usecase/orderbook_usecase_test.go @@ -0,0 +1,873 @@ +package orderbookusecase_test + +import ( + "context" + "errors" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + cwpoolmodel "github.com/osmosis-labs/osmosis/v25/x/cosmwasmpool/model" + poolmanagertypes "github.com/osmosis-labs/osmosis/v25/x/poolmanager/types" + "github.com/osmosis-labs/sqs/log" + "github.com/osmosis-labs/sqs/sqsdomain" + + cltypes "github.com/osmosis-labs/osmosis/v25/x/concentrated-liquidity/types" + "github.com/osmosis-labs/sqs/domain" + "github.com/osmosis-labs/sqs/domain/mocks" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" + orderbookgrpcclientdomain "github.com/osmosis-labs/sqs/domain/orderbook/grpcclient" + "github.com/osmosis-labs/sqs/orderbook/types" + orderbookusecase "github.com/osmosis-labs/sqs/orderbook/usecase" + "github.com/osmosis-labs/sqs/orderbook/usecase/orderbooktesting" + "github.com/osmosis-labs/sqs/sqsdomain/cosmwasmpool" + + "github.com/osmosis-labs/osmosis/osmomath" +) + +// OrderbookUsecaseTestSuite is a test suite for the orderbook usecase +type OrderbookUsecaseTestSuite struct { + orderbooktesting.OrderbookTestHelper +} + +// SetupTest sets up the test suite +func TestOrderbookUsecaseTestSuite(t *testing.T) { + suite.Run(t, new(OrderbookUsecaseTestSuite)) +} + +func (s *OrderbookUsecaseTestSuite) GetSpotPriceScalingFactorByDenomFunc(v int64, err error) func(baseDenom, quoteDenom string) (osmomath.Dec, error) { + return func(baseDenom, quoteDenom string) (osmomath.Dec, error) { + return osmomath.NewDec(v), err + } +} + +func (s *OrderbookUsecaseTestSuite) getTickByIDFunc(tick orderbookdomain.OrderbookTick, ok bool) func(poolID uint64, tickID int64) (orderbookdomain.OrderbookTick, bool) { + return func(poolID uint64, tickID int64) (orderbookdomain.OrderbookTick, bool) { + return tick, ok + } +} + +func (s *OrderbookUsecaseTestSuite) TestProcessPool() { + withContractInfo := func(pool *mocks.MockRoutablePool) *mocks.MockRoutablePool { + pool.CosmWasmPoolModel.ContractInfo = cosmwasmpool.ContractInfo{ + Contract: cosmwasmpool.ORDERBOOK_CONTRACT_NAME, + Version: cosmwasmpool.ORDERBOOK_MIN_CONTRACT_VERSION, + } + return pool + } + + withTicks := func(pool *mocks.MockRoutablePool, ticks []cosmwasmpool.OrderbookTick) *mocks.MockRoutablePool { + pool.CosmWasmPoolModel.Data = cosmwasmpool.CosmWasmPoolData{ + Orderbook: &cosmwasmpool.OrderbookData{ + Ticks: ticks, + }, + } + + return pool + } + + withChainModel := func(pool *mocks.MockRoutablePool, chainPoolModel poolmanagertypes.PoolI) *mocks.MockRoutablePool { + pool.ChainPoolModel = chainPoolModel + return pool + } + + pool := func() *mocks.MockRoutablePool { + return &mocks.MockRoutablePool{ + CosmWasmPoolModel: &cosmwasmpool.CosmWasmPoolModel{}, + } + } + + poolWithTicks := func() *mocks.MockRoutablePool { + return withTicks(withContractInfo(pool()), []cosmwasmpool.OrderbookTick{{TickId: 1}}) + } + + poolWithChainModel := func() *mocks.MockRoutablePool { + return withChainModel(poolWithTicks(), &cwpoolmodel.CosmWasmPool{}) + } + + testCases := []struct { + name string + pool sqsdomain.PoolI + setupMocks func(usecase *orderbookusecase.OrderbookUseCaseImpl, client *mocks.OrderbookGRPCClientMock, repository *mocks.OrderbookRepositoryMock) + expectedError error + }{ + { + name: "pool is nil", + pool: nil, + expectedError: &types.PoolNilError{}, + }, + { + name: "cosmWasmPoolModel is nil", + pool: &mocks.MockRoutablePool{ + CosmWasmPoolModel: nil, + }, + expectedError: &types.CosmWasmPoolModelNilError{}, + }, + { + name: "pool is not an orderbook pool", + pool: &mocks.MockRoutablePool{ + ID: 1, + CosmWasmPoolModel: &cosmwasmpool.CosmWasmPoolModel{}, + }, + expectedError: &types.NotAnOrderbookPoolError{}, + }, + { + name: "orderbook pool has no ticks, nothing to process", + pool: withTicks(withContractInfo(pool()), []cosmwasmpool.OrderbookTick{}), + expectedError: nil, + }, + { + name: "failed to cast pool model to CosmWasmPool", + pool: withChainModel(poolWithTicks(), &mocks.ChainPoolMock{ + ID: 1, + Type: poolmanagertypes.Balancer, + }), + expectedError: &types.FailedToCastPoolModelError{}, + }, + { + name: "failed to fetch ticks for pool", + pool: poolWithChainModel(), + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, client *mocks.OrderbookGRPCClientMock, repository *mocks.OrderbookRepositoryMock) { + client.FetchTicksCb = func(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]orderbookdomain.Tick, error) { + return nil, assert.AnError + } + }, + expectedError: &types.FetchTicksError{}, + }, + { + name: "failed to fetch unrealized cancels for pool", + pool: poolWithChainModel(), + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, client *mocks.OrderbookGRPCClientMock, repository *mocks.OrderbookRepositoryMock) { + client.FetchTickUnrealizedCancelsCb = func(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]orderbookgrpcclientdomain.UnrealizedTickCancels, error) { + return nil, assert.AnError + } + }, + expectedError: &types.FetchUnrealizedCancelsError{}, + }, + { + name: "tick ID mismatch when fetching unrealized ticks", + pool: poolWithChainModel(), + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, client *mocks.OrderbookGRPCClientMock, repository *mocks.OrderbookRepositoryMock) { + client.FetchTicksCb = func(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]orderbookdomain.Tick, error) { + return []orderbookdomain.Tick{ + {TickID: 1}, + }, nil + } + client.FetchTickUnrealizedCancelsCb = func(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]orderbookgrpcclientdomain.UnrealizedTickCancels, error) { + return []orderbookgrpcclientdomain.UnrealizedTickCancels{ + {TickID: 2}, // Mismatch + }, nil + } + }, + expectedError: &types.TickIDMismatchError{}, + }, + { + name: "tick ID mismatch when fetching tick states", + pool: poolWithChainModel(), + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, client *mocks.OrderbookGRPCClientMock, repository *mocks.OrderbookRepositoryMock) { + client.FetchTicksCb = func(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]orderbookdomain.Tick, error) { + return []orderbookdomain.Tick{ + {TickID: 2}, // Mismatched TickID + }, nil + } + client.FetchTickUnrealizedCancelsCb = func(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]orderbookgrpcclientdomain.UnrealizedTickCancels, error) { + return []orderbookgrpcclientdomain.UnrealizedTickCancels{ + {TickID: 1}, + }, nil + } + }, + expectedError: &types.TickIDMismatchError{}, + }, + { + name: "successful pool processing", + pool: poolWithChainModel(), + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, client *mocks.OrderbookGRPCClientMock, repository *mocks.OrderbookRepositoryMock) { + client.FetchTicksCb = func(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]orderbookdomain.Tick, error) { + return []orderbookdomain.Tick{ + {TickID: 1, TickState: orderbookdomain.TickState{ + BidValues: orderbookdomain.TickValues{ + EffectiveTotalAmountSwapped: "100", + }, + }}, + }, nil + } + client.FetchTickUnrealizedCancelsCb = func(ctx context.Context, chunkSize int, contractAddress string, tickIDs []int64) ([]orderbookgrpcclientdomain.UnrealizedTickCancels, error) { + return []orderbookgrpcclientdomain.UnrealizedTickCancels{ + { + TickID: 1, + UnrealizedCancelsState: orderbookdomain.UnrealizedCancels{ + BidUnrealizedCancels: osmomath.NewInt(100), + }, + }, + }, nil + } + repository.StoreTicksFunc = func(poolID uint64, ticksMap map[int64]orderbookdomain.OrderbookTick) { + // Assume ticks are correctly stored, no need for implementation here + } + }, + expectedError: nil, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + // Create instances of the mocks + repository := mocks.OrderbookRepositoryMock{} + tokensusecase := mocks.TokensUsecaseMock{} + client := mocks.OrderbookGRPCClientMock{} + + // Setup the mocks according to the test case + usecase := orderbookusecase.New(&repository, &client, nil, &tokensusecase, nil) + if tc.setupMocks != nil { + tc.setupMocks(usecase, &client, &repository) + } + + // Call the method under test + err := usecase.ProcessPool(context.Background(), tc.pool) + + // Assert the results + if tc.expectedError != nil { + s.Assert().Error(err) + s.Assert().ErrorAs(err, tc.expectedError) + } else { + s.Assert().NoError(err) + } + }) + } +} + +func (s *OrderbookUsecaseTestSuite) TestGetActiveOrders() { + testCases := []struct { + name string + setupContext func() context.Context + setupMocks func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, grpcclient *mocks.OrderbookGRPCClientMock, poolsUsecase *mocks.PoolsUsecaseMock, tokensusecase *mocks.TokensUsecaseMock) + address string + expectedError error + expectedOrders []orderbookdomain.LimitOrder + expectedIsBestEffort bool + }{ + { + name: "failed to get all canonical orderbook pool IDs", + setupContext: func() context.Context { + return context.Background() + }, + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, grpcclient *mocks.OrderbookGRPCClientMock, poolsUsecase *mocks.PoolsUsecaseMock, tokensusecase *mocks.TokensUsecaseMock) { + poolsUsecase.GetAllCanonicalOrderbookPoolIDsFunc = func() ([]domain.CanonicalOrderBooksResult, error) { + return nil, assert.AnError + } + }, + address: "osmo1npsku4qlqav6udkvgfk9eran4s4edzu69vzdm6", + expectedError: &types.FailedGetAllCanonicalOrderbookPoolIDsError{}, + }, + { + name: "context is done before processing all orderbooks", + setupContext: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }, + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, grpcclient *mocks.OrderbookGRPCClientMock, poolsUsecase *mocks.PoolsUsecaseMock, tokensusecase *mocks.TokensUsecaseMock) { + poolsUsecase.GetAllCanonicalOrderbookPoolIDsFunc = func() ([]domain.CanonicalOrderBooksResult, error) { + return []domain.CanonicalOrderBooksResult{ + {PoolID: 1}, + }, nil + } + }, + address: "osmo1glq2duq5f4x3m88fqwecfrfcuauy8343amy5fm", + expectedError: context.Canceled, + }, + { + name: "isBestEffort set to true when one orderbook is processed with best effort", + setupContext: func() context.Context { + return context.Background() + }, + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, grpcclient *mocks.OrderbookGRPCClientMock, poolsUsecase *mocks.PoolsUsecaseMock, tokensusecase *mocks.TokensUsecaseMock) { + poolsUsecase.GetAllCanonicalOrderbookPoolIDsFunc = s.GetAllCanonicalOrderbookPoolIDsFunc(nil, s.NewCanonicalOrderBooksResult(1, "A")) + + grpcclient.GetActiveOrdersCb = s.GetActiveOrdersFunc(orderbookdomain.Orders{s.NewOrder().Order}, 1, nil) + + tokensusecase.GetMetadataByChainDenomFunc = s.GetMetadataByChainDenomFuncEmptyToken() + + // Set is best effort to true + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(orderbookdomain.OrderbookTick{}, false) + }, + address: "osmo1777xu9gw22pham4yzssuywmxvel5wdyqkyacdw", + expectedError: nil, + expectedOrders: []orderbookdomain.LimitOrder{}, + expectedIsBestEffort: true, + }, + { + name: "successful retrieval of active orders", + setupContext: func() context.Context { + return context.Background() + }, + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, grpcclient *mocks.OrderbookGRPCClientMock, poolsUsecase *mocks.PoolsUsecaseMock, tokensusecase *mocks.TokensUsecaseMock) { + poolsUsecase.GetAllCanonicalOrderbookPoolIDsFunc = s.GetAllCanonicalOrderbookPoolIDsFunc(nil, s.NewCanonicalOrderBooksResult(1, "A")) + + grpcclient.GetActiveOrdersCb = s.GetActiveOrdersFunc(orderbookdomain.Orders{s.NewOrder().Order}, 1, nil) + + tokensusecase.GetMetadataByChainDenomFunc = s.GetMetadataByChainDenomFuncEmptyToken() + + tokensusecase.GetSpotPriceScalingFactorByDenomFunc = s.GetSpotPriceScalingFactorByDenomFunc(1, nil) + + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("500", 100, "bid"), true) + }, + address: "osmo1p2pq3dt5xkj39p0420p4mm9l45394xecr00299", + expectedError: nil, + expectedOrders: []orderbookdomain.LimitOrder{ + s.NewLimitOrder().WithOrderbookAddress("A").LimitOrder, + }, + expectedIsBestEffort: false, + }, + { + name: "successful retrieval of active orders: 3 orders returned. 1 from orderbook A, 2 from orderbook B -> 3 orders are returned as intended", + setupContext: func() context.Context { + return context.Background() + }, + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, grpcclient *mocks.OrderbookGRPCClientMock, poolsUsecase *mocks.PoolsUsecaseMock, tokensusecase *mocks.TokensUsecaseMock) { + poolsUsecase.GetAllCanonicalOrderbookPoolIDsFunc = s.GetAllCanonicalOrderbookPoolIDsFunc( + nil, + s.NewCanonicalOrderBooksResult(1, "A"), + s.NewCanonicalOrderBooksResult(1, "B"), + ) + + grpcclient.GetActiveOrdersCb = func(ctx context.Context, contractAddress string, ownerAddress string) (orderbookdomain.Orders, uint64, error) { + if contractAddress == "A" { + return orderbookdomain.Orders{ + s.NewOrder().WithOrderID(3).Order, + }, 1, nil + } + return orderbookdomain.Orders{ + s.NewOrder().WithOrderID(1).Order, + s.NewOrder().WithOrderID(2).Order, + }, 2, nil + } + + tokensusecase.GetMetadataByChainDenomFunc = s.GetMetadataByChainDenomFuncEmptyToken() + + tokensusecase.GetSpotPriceScalingFactorByDenomFunc = func(baseDenom, quoteDenom string) (osmomath.Dec, error) { + return osmomath.NewDec(1), nil + } + + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("500", 100, "bid"), true) + }, + address: "osmo1p2pq3dt5xkj39p0420p4mm9l45394xecr00299", + expectedError: nil, + expectedOrders: []orderbookdomain.LimitOrder{ + s.NewLimitOrder().WithOrderID(1).WithOrderbookAddress("B").LimitOrder, + s.NewLimitOrder().WithOrderID(2).WithOrderbookAddress("B").LimitOrder, + s.NewLimitOrder().WithOrderID(3).WithOrderbookAddress("A").LimitOrder, + }, + expectedIsBestEffort: false, + }, + { + name: "successful retrieval of active orders: 2 orders returned. 1 from orderbook A, 1 from order book B. Orderbook B is not canonical -> only 1 order is returned", + setupContext: func() context.Context { + return context.Background() + }, + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, grpcclient *mocks.OrderbookGRPCClientMock, poolsUsecase *mocks.PoolsUsecaseMock, tokensusecase *mocks.TokensUsecaseMock) { + poolsUsecase.GetAllCanonicalOrderbookPoolIDsFunc = s.GetAllCanonicalOrderbookPoolIDsFunc( + nil, + s.NewCanonicalOrderBooksResult(1, "A"), + s.NewCanonicalOrderBooksResult(0, "B"), // Not canonical + ) + + grpcclient.GetActiveOrdersCb = func(ctx context.Context, contractAddress string, ownerAddress string) (orderbookdomain.Orders, uint64, error) { + if contractAddress == "B" { + return orderbookdomain.Orders{ + s.NewOrder().WithOrderID(2).Order, + }, 1, nil + } + return orderbookdomain.Orders{ + s.NewOrder().WithOrderID(1).Order, + }, 2, nil + } + + tokensusecase.GetMetadataByChainDenomFunc = s.GetMetadataByChainDenomFuncEmptyToken() + + tokensusecase.GetSpotPriceScalingFactorByDenomFunc = func(baseDenom, quoteDenom string) (osmomath.Dec, error) { + return osmomath.NewDec(1), nil + } + + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("500", 100, "bid"), true) + }, + address: "osmo1p2pq3dt5xkj39p0420p4mm9l45394xecr00299", + expectedError: nil, + expectedOrders: []orderbookdomain.LimitOrder{ + s.NewLimitOrder().WithOrderID(1).WithOrderbookAddress("A").LimitOrder, + }, + expectedIsBestEffort: false, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + // Create instances of the mocks + poolsUsecase := mocks.PoolsUsecaseMock{} + orderbookrepositorysitory := mocks.OrderbookRepositoryMock{} + client := mocks.OrderbookGRPCClientMock{} + tokensusecase := mocks.TokensUsecaseMock{} + + // Setup the mocks according to the test case + usecase := orderbookusecase.New(&orderbookrepositorysitory, &client, &poolsUsecase, &tokensusecase, &log.NoOpLogger{}) + if tc.setupMocks != nil { + tc.setupMocks(usecase, &orderbookrepositorysitory, &client, &poolsUsecase, &tokensusecase) + } + + ctx := tc.setupContext() + + // Call the method under test + // We are not interested in the orders returned, it's tested + // in the TestCreateFormattedLimitOrder. + orders, isBestEffort, err := usecase.GetActiveOrders(ctx, tc.address) + + // Sort the results by order ID to make the output more deterministic + sort.SliceStable(orders, func(i, j int) bool { + return orders[i].OrderId < orders[j].OrderId + }) + + // Assert the results + if tc.expectedError != nil { + s.Assert().Error(err) + s.ErrorIsAs(err, tc.expectedError) + } else { + s.Assert().NoError(err) + s.Assert().Equal(tc.expectedIsBestEffort, isBestEffort) + s.Assert().Equal(tc.expectedOrders, orders) + } + }) + } +} + +func (s *OrderbookUsecaseTestSuite) TestProcessOrderBookActiveOrders() { + newLimitOrder := func() orderbooktesting.LimitOrder { + order := s.NewLimitOrder() + order = order.WithQuoteAsset(orderbookdomain.Asset{Symbol: "ATOM", Decimals: 6}) + order = order.WithBaseAsset(orderbookdomain.Asset{Symbol: "OSMO", Decimals: 6}) + return order + } + + testCases := []struct { + name string + setupMocks func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, tokensusecase *mocks.TokensUsecaseMock) + poolID uint64 + order orderbooktesting.LimitOrder + ownerAddress string + expectedError error + expectedOrders []orderbookdomain.LimitOrder + expectedIsBestEffort bool + }{ + { + name: "failed to get active orders", + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, tokensusecase *mocks.TokensUsecaseMock) { + client.GetActiveOrdersCb = s.GetActiveOrdersFunc(nil, 0, assert.AnError) + }, + poolID: 1, + order: newLimitOrder().WithOrderID(5), + ownerAddress: "osmo1epp52vecttkkvs3s84c9m8s2v2jrf7gtm3jzhg", + expectedError: &types.FailedToGetActiveOrdersError{}, + }, + { + name: "no active orders to process", + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, tokensusecase *mocks.TokensUsecaseMock) { + client.GetActiveOrdersCb = s.GetActiveOrdersFunc(nil, 0, nil) + }, + poolID: 83, + order: newLimitOrder().WithOrderbookAddress("A"), + ownerAddress: "osmo1h5la3t4y8cljl34lsqdszklvcn053u4ryz9qr78v64rsxezyxwlsdelsdr", + expectedError: nil, + expectedOrders: nil, + expectedIsBestEffort: false, + }, + { + name: "failed to get quote token metadata", + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, tokensusecase *mocks.TokensUsecaseMock) { + client.GetActiveOrdersCb = s.GetActiveOrdersFunc(orderbookdomain.Orders{s.NewOrder().Order}, 1, nil) + tokensusecase.GetMetadataByChainDenomFunc = s.GetMetadataByChainDenomFunc(newLimitOrder(), "quoteToken") + }, + poolID: 11, + order: newLimitOrder().WithOrderID(1), + ownerAddress: "osmo103l28g7r3q90d20vta2p2mz0x7qvdr3xgfwnas", + expectedError: &types.FailedToGetMetadataError{}, + }, + { + name: "failed to get base token metadata", + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, tokensusecase *mocks.TokensUsecaseMock) { + client.GetActiveOrdersCb = s.GetActiveOrdersFunc(orderbookdomain.Orders{s.NewOrder().Order}, 1, nil) + tokensusecase.GetMetadataByChainDenomFunc = s.GetMetadataByChainDenomFunc(newLimitOrder(), "quoteToken") + }, + poolID: 35, + order: newLimitOrder().WithOrderbookAddress("D"), + ownerAddress: "osmo1rlj2g3etczywhawuk7zh3tv8sp9edavvntn7jr", + expectedError: &types.FailedToGetMetadataError{}, + }, + { + name: "error on creating formatted limit order ( no error - best effort )", + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, tokensusecase *mocks.TokensUsecaseMock) { + client.GetActiveOrdersCb = s.GetActiveOrdersFunc(orderbookdomain.Orders{ + s.NewOrder().WithOrderID(1).WithTickID(1).Order, + s.NewOrder().WithOrderID(2).WithTickID(2).Order, + }, 1, nil) + tokensusecase.GetMetadataByChainDenomFunc = s.GetMetadataByChainDenomFunc(newLimitOrder(), "") + tokensusecase.GetSpotPriceScalingFactorByDenomFunc = s.GetSpotPriceScalingFactorByDenomFunc(1, nil) + orderbookrepository.GetTickByIDFunc = func(poolID uint64, tickID int64) (orderbookdomain.OrderbookTick, bool) { + tick := s.NewTick("500", 100, "bid") + if tickID == 1 { + return tick, true + } + return tick, false + } + }, + poolID: 5, + order: newLimitOrder().WithOrderID(2), + ownerAddress: "osmo1c8udna9h9zsm44jav39g20dmtf7xjnrclpn5fw", + expectedError: nil, + expectedOrders: []orderbookdomain.LimitOrder{ + newLimitOrder().WithOrderID(1).LimitOrder, + }, + expectedIsBestEffort: true, + }, + { + name: "successful processing of 1 active order", + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, tokensusecase *mocks.TokensUsecaseMock) { + client.GetActiveOrdersCb = s.GetActiveOrdersFunc(orderbookdomain.Orders{s.NewOrder().Order}, 1, nil) + tokensusecase.GetMetadataByChainDenomFunc = s.GetMetadataByChainDenomFunc(newLimitOrder(), "") + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("500", 100, "bid"), true) + tokensusecase.GetSpotPriceScalingFactorByDenomFunc = s.GetSpotPriceScalingFactorByDenomFunc(1, nil) + }, + + poolID: 39, + order: newLimitOrder().WithOrderbookAddress("B"), + ownerAddress: "osmo1xhkvmfyfll0303s7xm9hh8uzzwehd98tuyjpga", + expectedError: nil, + expectedOrders: []orderbookdomain.LimitOrder{ + newLimitOrder().WithOrderbookAddress("B").LimitOrder, + }, + expectedIsBestEffort: false, + }, + { + name: "successful processing of 2 active orders", + setupMocks: func(usecase *orderbookusecase.OrderbookUseCaseImpl, orderbookrepository *mocks.OrderbookRepositoryMock, client *mocks.OrderbookGRPCClientMock, tokensusecase *mocks.TokensUsecaseMock) { + client.GetActiveOrdersCb = s.GetActiveOrdersFunc(orderbookdomain.Orders{ + s.NewOrder().WithOrderID(1).Order, + s.NewOrder().WithOrderID(2).Order, + }, 1, nil) + tokensusecase.GetMetadataByChainDenomFunc = s.GetMetadataByChainDenomFunc(newLimitOrder().WithBaseAsset(orderbookdomain.Asset{Symbol: "USDC", Decimals: 6}), "") + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("500", 100, "bid"), true) + tokensusecase.GetSpotPriceScalingFactorByDenomFunc = s.GetSpotPriceScalingFactorByDenomFunc(1, nil) + }, + poolID: 93, + order: newLimitOrder().WithBaseAsset(orderbookdomain.Asset{Symbol: "USDC", Decimals: 6}), + ownerAddress: "osmo1xhkvmfyfll0303s7xm9hh8uzzwehd98tuyjpga", + expectedError: nil, + expectedOrders: []orderbookdomain.LimitOrder{ + newLimitOrder().WithOrderID(1).WithBaseAsset(orderbookdomain.Asset{Symbol: "USDC", Decimals: 6}).LimitOrder, + newLimitOrder().WithOrderID(2).WithBaseAsset(orderbookdomain.Asset{Symbol: "USDC", Decimals: 6}).LimitOrder, + }, + expectedIsBestEffort: false, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + // Create instances of the mocks + client := mocks.OrderbookGRPCClientMock{} + tokensusecase := mocks.TokensUsecaseMock{} + orderbookrepository := mocks.OrderbookRepositoryMock{} + + // Setup the mocks according to the test case + usecase := orderbookusecase.New(&orderbookrepository, &client, nil, &tokensusecase, &log.NoOpLogger{}) + if tc.setupMocks != nil { + tc.setupMocks(usecase, &orderbookrepository, &client, &tokensusecase) + } + + // Call the method under test + orders, isBestEffort, err := usecase.ProcessOrderBookActiveOrders(context.Background(), domain.CanonicalOrderBooksResult{ + ContractAddress: tc.order.OrderbookAddress, + PoolID: tc.poolID, + Quote: tc.order.QuoteAsset.Symbol, + Base: tc.order.BaseAsset.Symbol, + }, tc.ownerAddress) + + // Assert the results + if tc.expectedError != nil { + s.Assert().Error(err) + if errors.Is(err, tc.expectedError) { + s.Assert().ErrorIs(err, tc.expectedError) + } else { + s.Assert().ErrorAs(err, tc.expectedError) + } + } else { + s.Assert().NoError(err) + s.Assert().Equal(tc.expectedOrders, orders) + s.Assert().Equal(tc.expectedIsBestEffort, isBestEffort) + } + }) + } +} + +func (s *OrderbookUsecaseTestSuite) TestCreateFormattedLimitOrder() { + // Generates a string that overflows when converting to osmomath.Dec + overflowDecStr := func() string { + return "9223372036854775808" + strings.Repeat("0", 100000) + } + + testCases := []struct { + name string + poolID uint64 + order orderbookdomain.Order + quoteAsset orderbookdomain.Asset + baseAsset orderbookdomain.Asset + orderbookAddress string + setupMocks func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) + expectedError error + expectedOrder orderbookdomain.LimitOrder + }{ + { + name: "tick not found", + order: orderbookdomain.Order{ + TickId: 99, // Non-existent tick ID + }, + orderbookAddress: "osmo10dl92ghwn3v44pd8w24c3htqn2mj29549zcsn06usr56ng9ppp0qe6wd0r", + expectedError: &types.TickForOrderbookNotFoundError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(orderbookdomain.OrderbookTick{}, false) + }, + }, + { + name: "error parsing quantity", + order: orderbookdomain.Order{ + Quantity: "invalid", // Invalid quantity + }, + orderbookAddress: "osmo1xvmtylht48gyvwe2s5rf3w6kn5g9rc4s0da0v0md82t9ldx447gsk07thg", + expectedError: &types.ParsingQuantityError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("6431", 935, "ask"), true) + }, + }, + { + name: "overflow in quantity", + order: orderbookdomain.Order{ + Quantity: overflowDecStr(), + PlacedQuantity: "1500", + Etas: "500", + ClaimBounty: "10", + }, + orderbookAddress: "osmo1rummy6vy4pfm82ctzmz4rr6fxgk0y4jf8h5s7zsadr2znwtuvq7slvl7p4", + expectedError: &types.ParsingQuantityError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("500", 100, "bid"), true) + }, + }, + { + name: "error parsing placed quantity", + order: orderbookdomain.Order{ + Quantity: "1000", + PlacedQuantity: "invalid", // Invalid placed quantity + }, + orderbookAddress: "osmo1pwnxmmynz4esx79qv60cshhxkuu0glmzltsaykhccnq7jmj7tvsqdumey8", + expectedError: &types.ParsingPlacedQuantityError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("813", 1331, "bid"), true) + }, + }, + { + name: "overflow in placed quantity", + order: orderbookdomain.Order{ + Quantity: "1000", + PlacedQuantity: overflowDecStr(), + Etas: "500", + ClaimBounty: "10", + }, + orderbookAddress: "osmo1z6h6etav6mfljq66vej7eqwsu4kummg9dfkvs969syw09fm0592s3fwgcs", + expectedError: &types.ParsingPlacedQuantityError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("500", 100, "bid"), true) + }, + }, + { + name: "placed quantity is zero", + order: orderbookdomain.Order{ + Quantity: "1000", + PlacedQuantity: "0", // division by zero + }, + orderbookAddress: "osmo1w8jm03vws7h448yvh83utd8p43j02npydy2jll0r0k7f6w7hjspsvw2u42", + expectedError: &types.InvalidPlacedQuantityError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("813", 1331, "bid"), true) + }, + }, + { + name: "error getting spot price scaling factor", + order: orderbookdomain.Order{ + Quantity: "931", + PlacedQuantity: "183", + }, + orderbookAddress: "osmo197hxw89l3gqn5ake3l5as0zh2ls6e52ata2sgq80lep0854dwe5sstljsp", + expectedError: &types.GettingSpotPriceScalingFactorError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("130", 13, "ask"), true) + tokensusecase.GetSpotPriceScalingFactorByDenomFunc = s.GetSpotPriceScalingFactorByDenomFunc(1, assert.AnError) + }, + }, + { + name: "error parsing bid effective total amount swapped", + order: orderbookdomain.Order{ + Quantity: "136", + PlacedQuantity: "131", + OrderDirection: "bid", + }, + orderbookAddress: "osmo1s552kx03vsr7ha5ck0k9tmg74gn4w72fmmjcqgr4ky3wf96wwpcqlg7vn9", + expectedError: &types.ParsingTickValuesError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("invalid", 13, "bid"), true) + }, + }, + { + name: "error parsing ask effective total amount swapped", + order: orderbookdomain.Order{ + Quantity: "136", + PlacedQuantity: "131", + OrderDirection: "ask", + }, + orderbookAddress: "osmo1yuz6952hrcx0hadq4mgg6fq3t04d4kxhzwsfezlvvsvhq053qyys5udd8z", + expectedError: &types.ParsingTickValuesError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("invalid", 1, "ask"), true) + }, + }, + { + name: "error parsing bid unrealized cancels", + order: orderbookdomain.Order{ + Quantity: "103", + PlacedQuantity: "153", + OrderDirection: "bid", + }, + orderbookAddress: "osmo1apmfjhycfh4cyvc7e6px4vtfwhnl5k4l0ssjq9el4rqx8kxzh2mq5gm3j9", + expectedError: &types.ParsingUnrealizedCancelsError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("15", 0, "bid"), true) + }, + }, + { + name: "error parsing ask unrealized cancels", + order: orderbookdomain.Order{ + Quantity: "133", + PlacedQuantity: "313", + OrderDirection: "ask", + }, + orderbookAddress: "osmo17qvca7z822w5hy6jxzvaut46k44tlyk4fshx9aklkzq6prze4s9q73u4wz", + expectedError: &types.ParsingUnrealizedCancelsError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("13", 0, "ask"), true) + }, + }, + { + name: "error parsing etas", + order: orderbookdomain.Order{ + Quantity: "1000", + PlacedQuantity: "1500", + OrderDirection: "bid", + Etas: "invalid", // Invalid ETAs + }, + orderbookAddress: "osmo1dkqnzv7r5wgq08yaj7cxpqy766mwneec2z2agke2l59x7qxff5sqzd2y5l", + expectedError: &types.ParsingEtasError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("386", 830, "bid"), true) + }, + }, + { + name: "overflow in etas", + order: orderbookdomain.Order{ + Quantity: "13500", + PlacedQuantity: "33500", + OrderDirection: "bid", + Etas: overflowDecStr(), // overflow value for ETAs + ClaimBounty: "10", + }, + orderbookAddress: "osmo1nkt9lwky3l3gnrdjw075u557fhzxn9ke085uxnxvtkpj6kz2asrqkd65ra", + expectedError: &types.ParsingEtasError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("500", 100, "bid"), true) + }, + }, + { + name: "error converting tick to price", + order: orderbookdomain.Order{ + TickId: cltypes.MinCurrentTickV2 - 1, // Invalid tick ID + Quantity: "1000", + PlacedQuantity: "1500", + OrderDirection: "ask", + Etas: "100", + }, + orderbookAddress: "osmo1nzpy57uftd877avsgfqjnqtsg5jhnzt8uv8mmytnku7lt76qa4lqds80nn", + expectedError: &types.ConvertingTickToPriceError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("190", 150, "ask"), true) + }, + }, + { + name: "error parsing placed_at", + order: orderbookdomain.Order{ + TickId: 1, + Quantity: "1000", + PlacedQuantity: "1500", + OrderDirection: "ask", + Etas: "100", + PlacedAt: "invalid", // Invalid timestamp + }, + orderbookAddress: "osmo1ewuvnvtvh5jrcve9v8txr9eqnnq9x9vq82ujct53yzt2jpc8usjsyx72sr", + expectedError: &types.ParsingPlacedAtError{}, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("100", 100, "ask"), true) + tokensusecase.GetSpotPriceScalingFactorByDenomFunc = s.GetSpotPriceScalingFactorByDenomFunc(10, nil) + }, + }, + { + name: "successful order processing", + order: s.NewOrder().Order, + setupMocks: func(orderbookrepository *mocks.OrderbookRepositoryMock, tokensusecase *mocks.TokensUsecaseMock) { + orderbookrepository.GetTickByIDFunc = s.GetTickByIDFunc(s.NewTick("500", 100, "bid"), true) + tokensusecase.GetSpotPriceScalingFactorByDenomFunc = s.GetSpotPriceScalingFactorByDenomFunc(1, nil) + }, + orderbookAddress: "osmo1kfct7fcu3qqc9jlxeku873p7t5vucfzw5ujn0dh97hypg24t2w6qe9q5zs", + expectedError: nil, + expectedOrder: s.NewLimitOrder().WithOrderbookAddress("osmo1kfct7fcu3qqc9jlxeku873p7t5vucfzw5ujn0dh97hypg24t2w6qe9q5zs").LimitOrder, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + // Create instances of the mocks + orderbookrepository := mocks.OrderbookRepositoryMock{} + tokensusecase := mocks.TokensUsecaseMock{} + + // Setup the mocks according to the test case + tc.setupMocks(&orderbookrepository, &tokensusecase) + + // Initialize the use case with the mocks + usecase := orderbookusecase.New( + &orderbookrepository, + nil, + nil, + &tokensusecase, + nil, + ) + + // Call the method under test + result, err := usecase.CreateFormattedLimitOrder(tc.poolID, tc.order, tc.quoteAsset, tc.baseAsset, tc.orderbookAddress) + + // Assert the results + if tc.expectedError != nil { + s.Assert().Error(err) + s.Assert().ErrorAs(err, tc.expectedError) + } else { + s.Assert().NoError(err) + s.Assert().Equal(tc.expectedOrder, result) + } + }) + } +} diff --git a/orderbook/usecase/orderbooktesting/parsing/active_orders_response.json b/orderbook/usecase/orderbooktesting/parsing/active_orders_response.json new file mode 100644 index 000000000..b96bbdf19 --- /dev/null +++ b/orderbook/usecase/orderbooktesting/parsing/active_orders_response.json @@ -0,0 +1,53 @@ +{ + "orders": [ + { + "tick_id": 1, + "order_id": 1, + "order_direction": "bid", + "owner": "owner1", + "quantity": "1000.000000000000000000", + "etas": "500", + "claim_bounty": "10", + "placed_quantity": "1500.000000000000000000", + "placed_at": 1634, + "price": "1.000001000000000000", + "percentClaimed": "0.333333333333333333", + "totalFilled": "600.000000000000000000", + "percentFilled": "0.400000000000000000", + "orderbookAddress": "someOrderbookAddress", + "status": "partiallyFilled", + "output": "1499.998500001499998500", + "quote_asset": { + "symbol": "" + }, + "base_asset": { + "symbol": "" + } + }, + { + "tick_id": 1, + "order_id": 2, + "order_direction": "bid", + "owner": "owner1", + "quantity": "1000.000000000000000000", + "etas": "500", + "claim_bounty": "10", + "placed_quantity": "1500.000000000000000000", + "placed_at": 1634, + "price": "1.000001000000000000", + "percentClaimed": "0.333333333333333333", + "totalFilled": "600.000000000000000000", + "percentFilled": "0.400000000000000000", + "orderbookAddress": "someOrderbookAddress", + "status": "partiallyFilled", + "output": "1499.998500001499998500", + "quote_asset": { + "symbol": "" + }, + "base_asset": { + "symbol": "" + } + } + ], + "is_best_effort": false +} diff --git a/orderbook/usecase/orderbooktesting/suite.go b/orderbook/usecase/orderbooktesting/suite.go new file mode 100644 index 000000000..c256e5566 --- /dev/null +++ b/orderbook/usecase/orderbooktesting/suite.go @@ -0,0 +1,208 @@ +package orderbooktesting + +import ( + "context" + + "github.com/stretchr/testify/assert" + + "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/osmosis/osmomath" +) + +// defaultOrder is a default order used for testing +var defaultOrder = orderbookdomain.Order{ + TickId: 1, + OrderId: 1, + OrderDirection: "bid", + Owner: "owner1", + Quantity: "1000", + PlacedQuantity: "1500", + Etas: "500", + ClaimBounty: "10", + PlacedAt: "1634764800000", +} + +// defaultLimitOrder is a default limit order used for testing +var defaultLimitOrder = orderbookdomain.LimitOrder{ + TickId: 1, + OrderId: 1, + OrderDirection: "bid", + Owner: "owner1", + Quantity: osmomath.NewDec(1000), + Etas: "500", + ClaimBounty: "10", + PlacedQuantity: osmomath.NewDec(1500), + PlacedAt: 1634, + Price: osmomath.MustNewDecFromStr("1.000001000000000000"), + PercentClaimed: osmomath.MustNewDecFromStr("0.333333333333333333"), + TotalFilled: osmomath.MustNewDecFromStr("600"), + PercentFilled: osmomath.MustNewDecFromStr("0.400000000000000000"), + OrderbookAddress: "someOrderbookAddress", + Status: "partiallyFilled", + Output: osmomath.MustNewDecFromStr("1499.998500001499998500"), +} + +// Order is a wrapper around orderbookdomain.Order +// it wraps additional helper methods for testing +type Order struct { + orderbookdomain.Order +} + +// WithOrderID sets the order ID for the order +func (o Order) WithOrderID(id int64) Order { + o.OrderId = id + return o +} + +// WithTickID sets the tick ID for the order +func (o Order) WithTickID(id int64) Order { + o.TickId = id + return o +} + +// LimitOrder wraps additional helper methods for testing +type LimitOrder struct { + orderbookdomain.LimitOrder +} + +// WithOrderID sets the order ID for the order +func (o LimitOrder) WithOrderID(id int64) LimitOrder { + o.OrderId = id + return o +} + +// WithOrderbookAddress sets the orderbook address for the order +func (o LimitOrder) WithOrderbookAddress(address string) LimitOrder { + o.OrderbookAddress = address + return o +} + +// WithQuoteAsset sets the quote asset for the order +func (o LimitOrder) WithQuoteAsset(asset orderbookdomain.Asset) LimitOrder { + o.QuoteAsset = asset + return o +} + +// WithBaseAsset sets the base asset for the order +func (o LimitOrder) WithBaseAsset(asset orderbookdomain.Asset) LimitOrder { + o.BaseAsset = asset + return o +} + +// OrderbookTestHelper is a helper struct for the orderbook usecase tests +type OrderbookTestHelper struct { + routertesting.RouterTestHelper +} + +// NewOrder creates a new order based on the defaultOrder. +func (s *OrderbookTestHelper) NewOrder() Order { + return Order{defaultOrder} +} + +// NewLimitOrder creates a new limit order based on the defaultLimitOrder. +func (s *OrderbookTestHelper) NewLimitOrder() LimitOrder { + return LimitOrder{defaultLimitOrder} +} + +// NewTick creates a new orderbook tick +// direction can be either "bid" or "ask" and it determines the direction of the created tick. +func (s *OrderbookTestHelper) NewTick(effectiveTotalAmountSwapped string, unrealizedCancels int64, direction string) orderbookdomain.OrderbookTick { + s.T().Helper() + + tickValues := orderbookdomain.TickValues{ + EffectiveTotalAmountSwapped: effectiveTotalAmountSwapped, + } + + tick := orderbookdomain.OrderbookTick{ + TickState: orderbookdomain.TickState{}, + UnrealizedCancels: orderbookdomain.UnrealizedCancels{}, + } + + if direction == "bid" { + tick.TickState.BidValues = tickValues + if unrealizedCancels != 0 { + tick.UnrealizedCancels.BidUnrealizedCancels = osmomath.NewInt(unrealizedCancels) + } + } else { + tick.TickState.AskValues = tickValues + if unrealizedCancels != 0 { + tick.UnrealizedCancels.AskUnrealizedCancels = osmomath.NewInt(unrealizedCancels) + } + } + + return tick +} + +// GetTickByIDFunc returns a function that returns a tick by ID +// it is useful for mocking the repository.GetTickByIDFunc. +func (s *OrderbookTestHelper) GetTickByIDFunc(tick orderbookdomain.OrderbookTick, ok bool) func(poolID uint64, tickID int64) (orderbookdomain.OrderbookTick, bool) { + return func(poolID uint64, tickID int64) (orderbookdomain.OrderbookTick, bool) { + return tick, ok + } +} + +// NewCanonicalOrderBooksResult creates a new canonical orderbooks result +func (s *OrderbookTestHelper) NewCanonicalOrderBooksResult(poolID uint64, contractAddress string) domain.CanonicalOrderBooksResult { + return domain.CanonicalOrderBooksResult{ + Base: "OSMO", + Quote: "ATOM", + PoolID: poolID, + ContractAddress: contractAddress, + } +} + +// GetAllCanonicalOrderbookPoolIDsFunc returns a function that returns all canonical orderbook pool IDs +// it is useful for mocking the poolsUsecase.GetAllCanonicalOrderbookPoolIDsFunc. +func (s *OrderbookTestHelper) GetAllCanonicalOrderbookPoolIDsFunc(err error, orderbooks ...domain.CanonicalOrderBooksResult) func() ([]domain.CanonicalOrderBooksResult, error) { + return func() ([]domain.CanonicalOrderBooksResult, error) { + return orderbooks, err + } +} + +// GetActiveOrdersFunc returns a function that returns active orders +func (s *OrderbookTestHelper) GetActiveOrdersFunc(orders orderbookdomain.Orders, total uint64, err error) func(ctx context.Context, contractAddress string, ownerAddress string) (orderbookdomain.Orders, uint64, error) { + return func(ctx context.Context, contractAddress string, ownerAddress string) (orderbookdomain.Orders, uint64, error) { + return orders, total, err + } +} + +// GetMetadataByChainDenomFuncEmptyToken returns a function that returns an empty token useful for mocking the tokensUsecase.GetMetadataByChainDenomFunc +func (s *OrderbookTestHelper) GetMetadataByChainDenomFuncEmptyToken() func(denom string) (domain.Token, error) { + return func(denom string) (domain.Token, error) { + return domain.Token{}, nil + } +} + +// GetMetadataByChainDenomFunc returns a function that returns a token by chain denom useful for mocking the tokensUsecase.GetMetadataByChainDenomFunc +// If errIfNotDenom is not empty, it will return an error if the denom passed to GetMetadataByChainDenomFunc is not equal to errIfNotDenom. +// If the denom passed to GetMetadataByChainDenomFunc is empty, it will return an empty token. +// If the denom passed to GetMetadataByChainDenomFunc is equal to the quote/base asset symbol, it will return a token with the quote/base asset symbol and decimals. +func (s *OrderbookTestHelper) GetMetadataByChainDenomFunc(order LimitOrder, errIfNotDenom string) func(denom string) (domain.Token, error) { + return func(denom string) (domain.Token, error) { + if errIfNotDenom != "" && errIfNotDenom != denom { + return domain.Token{}, assert.AnError + } + if denom == "" { + return domain.Token{}, nil + } + + if denom == order.QuoteAsset.Symbol { + return domain.Token{ + CoinMinimalDenom: order.QuoteAsset.Symbol, + Precision: order.QuoteAsset.Decimals, + }, nil + } + + if denom == order.BaseAsset.Symbol { + return domain.Token{ + CoinMinimalDenom: order.BaseAsset.Symbol, + Precision: order.BaseAsset.Decimals, + }, nil + } + + return domain.Token{}, assert.AnError + } +} diff --git a/passthrough/delivery/http/passthrough_handler.go b/passthrough/delivery/http/passthrough_handler.go index b4b96103f..f328e2d7a 100644 --- a/passthrough/delivery/http/passthrough_handler.go +++ b/passthrough/delivery/http/passthrough_handler.go @@ -6,17 +6,13 @@ import ( deliveryhttp "github.com/osmosis-labs/sqs/delivery/http" "github.com/osmosis-labs/sqs/domain" "github.com/osmosis-labs/sqs/domain/mvc" + _ "github.com/osmosis-labs/sqs/domain/passthrough" "github.com/osmosis-labs/sqs/orderbook/types" "github.com/labstack/echo/v4" "go.opentelemetry.io/otel/trace" ) -// ResponseError represent the response error struct -type ResponseError struct { - Message string `json:"message"` -} - // PassthroughHandler is the http handler for passthrough use case type PassthroughHandler struct { PUsecase mvc.PassthroughUsecase @@ -46,25 +42,36 @@ func NewPassthroughHandler(e *echo.Echo, ptu mvc.PassthroughUsecase, ou mvc.Orde // The user balances and total assets are brokend down by-coin with the capitalization of the entire account value. // // @Produce json -// @Success 200 struct passthroughdomain.PortfolioAssetsResult "Portfolio assets by-category and capitalization of the entire account value" -// @Failure 500 struct ResponseError "Response error" +// @Success 200 {object} passthroughdomain.PortfolioAssetsResult "Portfolio assets by-category and capitalization of the entire account value" +// @Failure 500 {object} domain.ResponseError "Response error" // @Param address path string true "Wallet Address" // @Router /passthrough/portfolio-assets/{address} [get] func (a *PassthroughHandler) GetPortfolioAssetsByAddress(c echo.Context) error { address := c.Param("address") if address == "" { - return c.JSON(http.StatusInternalServerError, ResponseError{Message: "invalid address: cannot be empty"}) + return c.JSON(http.StatusInternalServerError, domain.ResponseError{Message: "invalid address: cannot be empty"}) } portfolioAssetsResult, err := a.PUsecase.GetPortfolioAssets(c.Request().Context(), address) if err != nil { - return c.JSON(http.StatusPartialContent, ResponseError{Message: err.Error()}) + return c.JSON(http.StatusPartialContent, domain.ResponseError{Message: err.Error()}) } return c.JSON(http.StatusOK, portfolioAssetsResult) } +// @Summary Returns all active orderbook orders associated with the given address. +// @Description The returned data represents all active orders for all orderbooks available for the specified address. +// +// The is_best_effort flag indicates whether the error occurred while processing the orders due which not all orders were returned in the response. +// +// @Produce json +// @Success 200 {object} types.GetActiveOrdersResponse "List of active orders for all available orderboooks for the given address" +// @Failure 400 {object} domain.ResponseError "Response error" +// @Failure 500 {object} domain.ResponseError "Response error" +// @Param userOsmoAddress query string true "Osmo wallet address" +// @Router /passthrough/active-orders [get] func (a *PassthroughHandler) GetActiveOrders(c echo.Context) (err error) { ctx := c.Request().Context() @@ -91,7 +98,7 @@ func (a *PassthroughHandler) GetActiveOrders(c echo.Context) (err error) { orders, isBestEffort, err := a.OUsecase.GetActiveOrders(ctx, req.UserOsmoAddress) if err != nil { - return c.JSON(http.StatusInternalServerError, domain.ResponseError{Message: err.Error()}) + return c.JSON(http.StatusInternalServerError, domain.ResponseError{Message: types.ErrInternalError.Error()}) } resp := types.NewGetAllOrderResponse(orders, isBestEffort) diff --git a/passthrough/delivery/http/passthrough_handler_test.go b/passthrough/delivery/http/passthrough_handler_test.go new file mode 100644 index 000000000..c07b910e9 --- /dev/null +++ b/passthrough/delivery/http/passthrough_handler_test.go @@ -0,0 +1,117 @@ +package http_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/osmosis-labs/sqs/domain/mocks" + orderbookdomain "github.com/osmosis-labs/sqs/domain/orderbook" + "github.com/osmosis-labs/sqs/orderbook/types" + "github.com/osmosis-labs/sqs/orderbook/usecase/orderbooktesting" + passthroughdelivery "github.com/osmosis-labs/sqs/passthrough/delivery/http" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type PassthroughHandlerTestSuite struct { + orderbooktesting.OrderbookTestHelper +} + +func TestPassthroughHandlerSuite(t *testing.T) { + suite.Run(t, new(PassthroughHandlerTestSuite)) +} + +func (s *PassthroughHandlerTestSuite) TestGetActiveOrders() { + testCases := []struct { + name string + queryParams map[string]string + setupMocks func(usecase *mocks.OrderbookUsecaseMock) + expectedStatusCode int + expectedResponse string + expectedError bool + }{ + { + name: "validation error", + queryParams: map[string]string{}, + setupMocks: func(usecase *mocks.OrderbookUsecaseMock) {}, + expectedStatusCode: http.StatusBadRequest, + expectedResponse: fmt.Sprintf(`{"message":"%s"}`, types.ErrUserOsmoAddressInvalid.Error()), + expectedError: true, + }, + { + name: "returns a few active orders", + queryParams: map[string]string{ + "userOsmoAddress": "osmo1ugku28hwyexpljrrmtet05nd6kjlrvr9jz6z00", + }, + setupMocks: func(usecase *mocks.OrderbookUsecaseMock) { + usecase.GetActiveOrdersFunc = func(ctx context.Context, address string) ([]orderbookdomain.LimitOrder, bool, error) { + return []orderbookdomain.LimitOrder{ + s.NewLimitOrder().WithOrderID(1).LimitOrder, + s.NewLimitOrder().WithOrderID(2).LimitOrder, + }, false, nil + } + }, + expectedStatusCode: http.StatusOK, + expectedResponse: s.MustReadFile("../../../orderbook/usecase/orderbooktesting/parsing/active_orders_response.json"), + expectedError: false, + }, + { + name: "internal server error from usecase", + queryParams: map[string]string{ + "userOsmoAddress": "osmo1ev0vtddkl7jlwfawlk06yzncapw2x9quva4wzw", + }, + setupMocks: func(usecase *mocks.OrderbookUsecaseMock) { + usecase.GetActiveOrdersFunc = func(ctx context.Context, address string) ([]orderbookdomain.LimitOrder, bool, error) { + return nil, false, assert.AnError + } + }, + expectedStatusCode: http.StatusInternalServerError, + expectedResponse: fmt.Sprintf(`{"message":"%s"}`, types.ErrInternalError.Error()), + expectedError: true, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + e := echo.New() + req := httptest.NewRequest(echo.GET, "/", nil) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + q := req.URL.Query() + for k, v := range tc.queryParams { + q.Add(k, v) + } + req.URL.RawQuery = q.Encode() + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + // Set up the mocks + usecase := mocks.OrderbookUsecaseMock{} + if tc.setupMocks != nil { + tc.setupMocks(&usecase) + } + + // Initialize the handler with mocked usecase + handler := passthroughdelivery.PassthroughHandler{OUsecase: &usecase} + + // Call the method under test + err := handler.GetActiveOrders(c) + + // Check the error condition + if tc.expectedError { + s.Assert().Nil(err) + } else { + s.Assert().NoError(err) + } + + // Check the response + s.Assert().Equal(tc.expectedStatusCode, rec.Code) + s.Assert().JSONEq(tc.expectedResponse, strings.TrimSpace(rec.Body.String())) + }) + } +} diff --git a/pools/delivery/http/pools_handler.go b/pools/delivery/http/pools_handler.go index 03f7007f3..ebd5cb61f 100644 --- a/pools/delivery/http/pools_handler.go +++ b/pools/delivery/http/pools_handler.go @@ -168,7 +168,7 @@ func getStatusCode(err error) int { // @Produce json // @Param base query string true "Base denom" // @Param quote query string true "Quote denom" -// @Success 200 struct domain.CanonicalOrderBooksResult "Canonical Orderbook Pool ID for the given base and quote" +// @Success 200 {object} domain.CanonicalOrderBooksResult "Canonical Orderbook Pool ID for the given base and quote" // @Router /pools/canonical-orderbook [get] func (a *PoolsHandler) GetCanonicalOrderbook(c echo.Context) error { base := c.QueryParam("base") diff --git a/router/usecase/routertesting/suite.go b/router/usecase/routertesting/suite.go index 5261e1d36..eb3e1ad95 100644 --- a/router/usecase/routertesting/suite.go +++ b/router/usecase/routertesting/suite.go @@ -1,6 +1,7 @@ package routertesting import ( + "errors" "fmt" "os" "path/filepath" @@ -457,3 +458,13 @@ func PrepareValidSortedRouterPools(pools []sqsdomain.PoolI, minPoolLiquidityCap return poolsAboveMinLiquidity } + +// ErrorIsAs first checks whether target error is equal to expectedError, if not, it checks whether +// at least one of the errors in target's chain matches expectedError. +func (s *RouterTestHelper) ErrorIsAs(target error, expectedError error) { + if errors.Is(target, expectedError) { + s.Assert().ErrorIs(target, expectedError) + } else { + s.Assert().ErrorAs(target, expectedError) + } +}