diff --git a/data/redisrepo/cancel_partial_filled_order.go b/data/redisrepo/cancel_partial_filled_order.go index e119536..bafffdb 100644 --- a/data/redisrepo/cancel_partial_filled_order.go +++ b/data/redisrepo/cancel_partial_filled_order.go @@ -12,7 +12,7 @@ import ( // Cancels a partial filled order. // Order is removed from the prices sorted set, user's order set and order hash is updated to cancelled -// May only be called if order is not pending and partially filled +// can be called to locked orders func (r *redisRepository) CancelPartialFilledOrder(ctx context.Context, order models.Order) error { if order.IsPending() { diff --git a/data/redisrepo/repository.go b/data/redisrepo/repository.go index d4c1839..5ff6524 100644 --- a/data/redisrepo/repository.go +++ b/data/redisrepo/repository.go @@ -7,15 +7,18 @@ import ( ) type redisRepository struct { - client redis.Cmdable + client redis.Cmdable + txMap map[uint]redis.Pipeliner + ixIndex uint } func NewRedisRepository(client redis.Cmdable) (*redisRepository, error) { if client == nil { return nil, fmt.Errorf("redis client cannot be nil") } - + txMap := make(map[uint]redis.Pipeliner) return &redisRepository{ client: client, + txMap: txMap, }, nil } diff --git a/data/redisrepo/tk_order_test.go b/data/redisrepo/tk_order_test.go new file mode 100644 index 0000000..5a899e9 --- /dev/null +++ b/data/redisrepo/tk_order_test.go @@ -0,0 +1,369 @@ +package redisrepo + +import ( + "context" + "testing" + "time" + + "github.com/go-redis/redismock/v9" + "github.com/orbs-network/order-book/mocks" + "github.com/orbs-network/order-book/models" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" +) + +// Init Redis repository with a mock client and a mock tx +func setupTest() (context.Context, redismock.ClientMock, *redisRepository) { + ctx := context.Background() + db, mock := redismock.NewClientMock() + + repo := &redisRepository{ + client: db, + txMap: make(map[uint]redis.Pipeliner), + } + + tx := db.TxPipeline() + txid := uint(1) + + repo.txMap[txid] = tx + + return ctx, mock, repo +} + +func TestRedisRepository_TxStartEndPerform(t *testing.T) { + + t.Run("txStart initializes a transaction", func(t *testing.T) { + db, mock := redismock.NewClientMock() + + repo := &redisRepository{ + client: db, + txMap: make(map[uint]redis.Pipeliner), + ixIndex: 0, + } + + mock.ExpectTxPipeline() + + txid := repo.txStart(context.Background()) + + assert.Equal(t, uint(1), txid) + assert.Contains(t, repo.txMap, txid) + }) + + t.Run("txEnd executes and clears transaction", func(t *testing.T) { + db, mock := redismock.NewClientMock() + pipeline := db.TxPipeline() + + repo := &redisRepository{ + client: db, + txMap: map[uint]redis.Pipeliner{1: pipeline}, + } + + err := repo.txEnd(context.Background(), 1) + assert.NoError(t, err) + + assert.NoError(t, mock.ExpectationsWereMet()) + assert.NotContains(t, repo.txMap, 1) + }) + + t.Run("PerformTx executes action within a transaction", func(t *testing.T) { + db, mock := redismock.NewClientMock() + + repo := &redisRepository{ + client: db, + txMap: make(map[uint]redis.Pipeliner), + ixIndex: 0, + } + + actionCalled := false + testAction := func(txid uint) error { + actionCalled = true + return nil + } + + err := repo.PerformTx(context.Background(), testAction) + + assert.NoError(t, err) + assert.True(t, actionCalled) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestRedisRepository_TxModifyOrder(t *testing.T) { + + ctx, mock, repo := setupTest() + + t.Run("successfully adds order", func(t *testing.T) { + + mock.ExpectTxPipeline() + mock.ExpectHSet(CreateOrderIDKey(mocks.Order.Id), mocks.Order.OrderToMap()).SetVal(1) + mock.ExpectTxPipelineExec() + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyOrder(ctx, txid, models.Add, mocks.Order) + }) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("successfully updates order", func(t *testing.T) { + + mock.ExpectTxPipeline() + mock.ExpectHSet(CreateOrderIDKey(mocks.Order.Id), mocks.Order.OrderToMap()).SetVal(1) + mock.ExpectTxPipelineExec() + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyOrder(ctx, txid, models.Update, mocks.Order) + }) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("successfully deletes order", func(t *testing.T) { + + mock.ExpectTxPipeline() + mock.ExpectDel(CreateOrderIDKey(mocks.Order.Id)).SetVal(1) + mock.ExpectTxPipelineExec() + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyOrder(ctx, txid, models.Remove, mocks.Order) + }) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("unsupported operation", func(t *testing.T) { + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyOrder(ctx, txid, 99, mocks.Order) + }) + + assert.ErrorIs(t, err, models.ErrUnsupportedOperation) + }) +} + +func TestRedisRepository_TxModifyPrices(t *testing.T) { + + ctx, mock, repo := setupTest() + + timestamp := time.Date(2023, 10, 10, 12, 0, 0, 0, time.UTC) + + t.Run("successfully adds to buy side prices key", func(t *testing.T) { + + var buyOrder = models.Order{ + Id: orderId, + ClientOId: clientOId, + Price: price, + Size: size, + Symbol: symbol, + Side: models.BUY, + Timestamp: timestamp, + } + + mock.ExpectTxPipeline() + mock.ExpectZAdd(CreateBuySidePricesKey(buyOrder.Symbol), redis.Z{ + Score: 10.0016969392, + Member: buyOrder.Id.String(), + }).SetVal(1) + mock.ExpectTxPipelineExec() + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyPrices(ctx, txid, models.Add, buyOrder) + }) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("successfully adds to sell side prices key", func(t *testing.T) { + + var sellOrder = models.Order{ + Id: orderId, + ClientOId: clientOId, + Price: price, + Size: size, + Symbol: symbol, + Side: models.SELL, + Timestamp: timestamp, + } + + mock.ExpectTxPipeline() + mock.ExpectZAdd(CreateSellSidePricesKey(sellOrder.Symbol), redis.Z{ + Score: 10.0016969392, + Member: sellOrder.Id.String(), + }).SetVal(1) + mock.ExpectTxPipelineExec() + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyPrices(ctx, txid, models.Add, sellOrder) + }) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("successfully deletes price", func(t *testing.T) { + + mock.ExpectTxPipeline() + mock.ExpectZRem(CreateBuySidePricesKey(mocks.Order.Symbol), mocks.Order.Id.String()).SetVal(1) + mock.ExpectTxPipelineExec() + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyPrices(ctx, txid, models.Remove, mocks.Order) + }) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("unsupported operation", func(t *testing.T) { + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyPrices(ctx, txid, 99, mocks.Order) + }) + + assert.ErrorIs(t, err, models.ErrUnsupportedOperation) + }) +} + +func TestRedisRepository_TxModifyClientOId(t *testing.T) { + + ctx, mock, repo := setupTest() + + t.Run("successfully adds clientOID", func(t *testing.T) { + + mock.ExpectTxPipeline() + mock.ExpectSet(CreateClientOIDKey(mocks.Order.ClientOId), mocks.Order.Id.String(), 0).SetVal("OK") + mock.ExpectTxPipelineExec() + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyClientOId(ctx, txid, models.Add, mocks.Order) + }) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("successfully deletes clientOID", func(t *testing.T) { + + mock.ExpectTxPipeline() + mock.ExpectDel(CreateClientOIDKey(mocks.Order.ClientOId)).SetVal(1) + mock.ExpectTxPipelineExec() + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyClientOId(ctx, txid, models.Remove, mocks.Order) + }) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("unsupported operation", func(t *testing.T) { + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyClientOId(ctx, txid, 99, mocks.Order) + }) + + assert.ErrorIs(t, err, models.ErrUnsupportedOperation) + }) +} + +func TestRedisRepository_TxModifyUserOpenOrders(t *testing.T) { + + ctx, mock, repo := setupTest() + timestamp := time.Date(2023, 10, 10, 12, 0, 0, 0, time.UTC) + order := models.Order{ + Timestamp: timestamp, + } + + t.Run("successfully adds user open order", func(t *testing.T) { + + mock.ExpectTxPipeline() + mock.ExpectZAdd(CreateUserOpenOrdersKey(order.UserId), redis.Z{ + Score: float64(timestamp.UnixNano()), + Member: order.Id.String(), + }).SetVal(1) + mock.ExpectTxPipelineExec() + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyUserOpenOrders(ctx, txid, models.Add, order) + }) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("successfully removes user open order", func(t *testing.T) { + + mock.ExpectTxPipeline() + mock.ExpectZRem(CreateUserOpenOrdersKey(order.UserId), order.Id.String()).SetVal(1) + mock.ExpectTxPipelineExec() + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyUserOpenOrders(ctx, txid, models.Remove, order) + }) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("unsupported operation", func(t *testing.T) { + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyUserOpenOrders(ctx, txid, 99, mocks.Order) + }) + + assert.ErrorIs(t, err, models.ErrUnsupportedOperation) + }) +} + +func TestRedisRepository_TxModifyUserFilledOrders(t *testing.T) { + + ctx, mock, repo := setupTest() + timestamp := time.Date(2023, 10, 10, 12, 0, 0, 0, time.UTC) + order := models.Order{ + Timestamp: timestamp, + } + + t.Run("successfully adds user filled order", func(t *testing.T) { + + mock.ExpectTxPipeline() + mock.ExpectZAdd(CreateUserFilledOrdersKey(order.UserId), redis.Z{ + Score: float64(timestamp.UnixNano()), + Member: order.Id.String(), + }).SetVal(1) + mock.ExpectTxPipelineExec() + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyUserFilledOrders(ctx, txid, models.Add, order) + }) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("successfully removes user filled order", func(t *testing.T) { + + mock.ExpectTxPipeline() + mock.ExpectZRem(CreateUserFilledOrdersKey(order.UserId), order.Id.String()).SetVal(1) + mock.ExpectTxPipelineExec() + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyUserFilledOrders(ctx, txid, models.Remove, order) + }) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("unsupported operation", func(t *testing.T) { + + err := repo.PerformTx(ctx, func(txid uint) error { + return repo.TxModifyUserFilledOrders(ctx, txid, 3, mocks.Order) + }) + + assert.ErrorIs(t, err, models.ErrUnsupportedOperation) + }) +} diff --git a/data/redisrepo/tx_order.go b/data/redisrepo/tx_order.go new file mode 100644 index 0000000..3542989 --- /dev/null +++ b/data/redisrepo/tx_order.go @@ -0,0 +1,227 @@ +package redisrepo + +import ( + "context" + "fmt" + + "github.com/orbs-network/order-book/models" + "github.com/orbs-network/order-book/utils/logger" + "github.com/orbs-network/order-book/utils/logger/logctx" + "github.com/redis/go-redis/v9" +) + +// Generic Building blocks with no biz logic in a single TX + +// Perform a transaction with a single action. This should be used for all interactions with the Redis repository. +// Handles the transaction lifecycle. +// The action function should be a single Redis command or a series of Redis commands that should be executed in a single transaction. +// See the methods below (eg. TxModifyOrder, TxModifyPrices, etc.) +func (r *redisRepository) PerformTx(ctx context.Context, action func(txid uint) error) error { + txid := r.txStart(ctx) + + err := action(txid) + if err != nil { + logctx.Error(ctx, "PerformTx action failed", logger.Error(err), logger.Int("txid", int(txid))) + return fmt.Errorf("PerformTx action failed: %w", err) + } + + err = r.txEnd(ctx, txid) + if err != nil { + logctx.Error(ctx, "PerformTx txEnd commit failed", logger.Error(err), logger.Int("txid", int(txid))) + return fmt.Errorf("PerformTx txEnd commit failed: %w", err) + } + + return nil +} + +// This should be used for all write interactions with the `order:` hash key +func (r *redisRepository) TxModifyOrder(ctx context.Context, txid uint, operation models.Operation, order models.Order) error { + var tx redis.Pipeliner + var ok bool + if tx, ok = r.txMap[txid]; !ok { + logctx.Error(ctx, "TxModifyOrder txid not found", logger.Int("txid", int(txid))) + return models.ErrNotFound + } + + switch operation { + case models.Add, models.Update: + // Store order details by order ID + orderIDKey := CreateOrderIDKey(order.Id) + orderMap := order.OrderToMap() + tx.HSet(ctx, orderIDKey, orderMap) + logctx.Debug(ctx, "TxModifyOrder add", logger.String("orderId", order.Id.String()), logger.String("orderMap", fmt.Sprintf("%v", orderMap))) + case models.Remove: + orderIDKey := CreateOrderIDKey(order.Id) + tx.Del(ctx, orderIDKey) + logctx.Debug(ctx, "TxModifyOrder remove", logger.String("orderId", order.Id.String())) + default: + logctx.Error(ctx, "TxModifyOrder unsupported operation", logger.Int("operation", int(operation))) + return models.ErrUnsupportedOperation + } + + return nil + +} + +// This should be used for all write interactions with the `prices::buy` and `prices::sell` sorted sets (used to store bid/ask prices for each token pair) +func (r *redisRepository) TxModifyPrices(ctx context.Context, txid uint, operation models.Operation, order models.Order) error { + var tx redis.Pipeliner + var ok bool + if tx, ok = r.txMap[txid]; !ok { + logctx.Error(ctx, "TxModifyPrices txid not found", logger.Int("txid", int(txid))) + return models.ErrNotFound + } + + switch operation { + case models.Add: + // Add order to the sorted set for that token pair + f64Price, _ := order.Price.Float64() + timestamp := float64(order.Timestamp.UTC().UnixNano()) / 1e9 + score := f64Price + (timestamp / 1e12) // Use a combination of price and scaled timestamp so that orders with the same price are sorted by time. THIS SHOULD NOT BE USED FOR PRICE COMPARISON. Rather, use the price field in the order struct. + + if order.Side == models.BUY { + buyPricesKey := CreateBuySidePricesKey(order.Symbol) + tx.ZAdd(ctx, buyPricesKey, redis.Z{ + Score: score, + Member: order.Id.String(), + }) + } else { + sellPricesKey := CreateSellSidePricesKey(order.Symbol) + tx.ZAdd(ctx, sellPricesKey, redis.Z{ + Score: score, + Member: order.Id.String(), + }) + } + logctx.Debug(ctx, "TxModifyPrices add", logger.String("orderId", order.Id.String()), logger.String("symbol", order.Symbol.String()), logger.String("side", order.Side.String())) + case models.Remove: + if order.Side == models.BUY { + buyPricesKey := CreateBuySidePricesKey(order.Symbol) + tx.ZRem(ctx, buyPricesKey, order.Id.String()) + } else { + sellPricesKey := CreateSellSidePricesKey(order.Symbol) + tx.ZRem(ctx, sellPricesKey, order.Id.String()) + } + logctx.Debug(ctx, "TxModifyPrices remove", logger.String("orderId", order.Id.String()), logger.String("symbol", order.Symbol.String()), logger.String("side", order.Side.String())) + default: + logctx.Error(ctx, "TxModifyPrices unsupported operation", logger.Int("operation", int(operation))) + return models.ErrUnsupportedOperation + } + return nil + +} + +// This should be used for all write interactions with the `clientOID:` hash key +func (r *redisRepository) TxModifyClientOId(ctx context.Context, txid uint, operation models.Operation, order models.Order) error { + var tx redis.Pipeliner + var ok bool + if tx, ok = r.txMap[txid]; !ok { + logctx.Error(ctx, "TxModifyClientOId txid not found", logger.Int("txid", int(txid))) + return models.ErrNotFound + } + + switch operation { + case models.Add: + clientOIdKey := CreateClientOIDKey(order.ClientOId) + tx.Set(ctx, clientOIdKey, order.Id.String(), 0) + logctx.Debug(ctx, "ModifyClientOId add", logger.String("clientOID", order.ClientOId.String()), logger.String("orderId", order.Id.String())) + case models.Remove: + clientOIdKey := CreateClientOIDKey(order.ClientOId) + tx.Del(ctx, clientOIdKey) + logctx.Debug(ctx, "ModifyClientOId remove", logger.String("clientOID", order.ClientOId.String()), logger.String("orderId", order.Id.String())) + default: + logctx.Error(ctx, "ModifyClientOId unsupported operation", logger.Int("operation", int(operation))) + return models.ErrUnsupportedOperation + } + return nil +} + +// This should be used for all write interactions with the `user::openOrders` sorted set (used to store open orders for each user) +func (r *redisRepository) TxModifyUserOpenOrders(ctx context.Context, txid uint, operation models.Operation, order models.Order) error { + var tx redis.Pipeliner + var ok bool + if tx, ok = r.txMap[txid]; !ok { + logctx.Error(ctx, "TxModifyUserOpenOrders txid not found", logger.Int("txid", int(txid))) + return models.ErrNotFound + } + + switch operation { + case models.Add: + userOrdersKey := CreateUserOpenOrdersKey(order.UserId) + userOrdersScore := float64(order.Timestamp.UTC().UnixNano()) + tx.ZAdd(ctx, userOrdersKey, redis.Z{ + Score: userOrdersScore, + Member: order.Id.String(), + }) + logctx.Debug(ctx, "ModifyUserOpenOrders add", logger.String("orderId", order.Id.String()), logger.String("userId", order.UserId.String())) + case models.Remove: + userOrdersKey := CreateUserOpenOrdersKey(order.UserId) + tx.ZRem(ctx, userOrdersKey, order.Id.String()) + logctx.Debug(ctx, "ModifyUserOpenOrders remove", logger.String("orderId", order.Id.String()), logger.String("userId", order.UserId.String())) + default: + logctx.Error(ctx, "ModifyUserOpenOrders unsupported operation", logger.Int("operation", int(operation))) + return models.ErrUnsupportedOperation + } + return nil +} + +// This should be used for all write interactions with the `user::filledOrders` sorted set (used to store partial-filled and cancelled OR fully filled orders for each user) +func (r *redisRepository) TxModifyUserFilledOrders(ctx context.Context, txid uint, operation models.Operation, order models.Order) error { + var tx redis.Pipeliner + var ok bool + if tx, ok = r.txMap[txid]; !ok { + logctx.Error(ctx, "TxModifyUserFilledOrders txid not found", logger.Int("txid", int(txid))) + return models.ErrNotFound + } + + switch operation { + case models.Add: + userFilledOrdersKey := CreateUserFilledOrdersKey(order.UserId) + userFilledOrdersScore := float64(order.Timestamp.UTC().UnixNano()) + tx.ZAdd(ctx, userFilledOrdersKey, redis.Z{ + Score: userFilledOrdersScore, + Member: order.Id.String(), + }) + logctx.Debug(ctx, "ModifyUserFilledOrders add", logger.String("orderId", order.Id.String()), logger.String("userId", order.UserId.String())) + case models.Remove: + userFilledOrdersKey := CreateUserFilledOrdersKey(order.UserId) + tx.ZRem(ctx, userFilledOrdersKey, order.Id.String()) + logctx.Debug(ctx, "ModifyUserFilledOrders remove", logger.String("orderId", order.Id.String()), logger.String("userId", order.UserId.String())) + default: + logctx.Error(ctx, "ModifyUserFilledOrders unsupported operation", logger.Int("operation", int(operation))) + return models.ErrUnsupportedOperation + } + return nil +} + +// Create a new transaction and return the transaction ID +func (r *redisRepository) txStart(ctx context.Context) uint { + tx := r.client.TxPipeline() + r.ixIndex += 1 + txid := r.ixIndex + r.txMap[txid] = tx + + return txid +} + +// Commit a given transaction +func (r *redisRepository) txEnd(ctx context.Context, txid uint) error { + var tx redis.Pipeliner + var ok bool + if tx, ok = r.txMap[txid]; !ok { + logctx.Error(ctx, "txEnd txid not found", logger.Int("txid", int(txid))) + return models.ErrNotFound + } + + cmderList, err := tx.Exec(ctx) + + for _, cmder := range cmderList { + logctx.Debug(ctx, "Command executed in transaction", logger.String("command", cmder.String())) + } + + if err != nil { + logctx.Error(ctx, "txEnd transaction exec failed", logger.Error(err), logger.Int("txid", int(txid))) + return fmt.Errorf("txEnd transaction exec failed for txId %q: %w", txid, err) + } + + return nil +} diff --git a/data/store/store.go b/data/store/store.go index 5d43e6d..a60831c 100644 --- a/data/store/store.go +++ b/data/store/store.go @@ -40,6 +40,17 @@ type OrderBookStore interface { GetMarketDepth(ctx context.Context, symbol models.Symbol, depth int) (models.MarketDepth, error) GetOrdersForUser(ctx context.Context, userId uuid.UUID, isFilledOrders bool) (orders []models.Order, totalOrders int, err error) CancelOrdersForUser(ctx context.Context, userId uuid.UUID) ([]uuid.UUID, error) + // ------------------------------ + // Generic Building blocks with no biz logic in a single + + // PerformTX should be used for all interactions with the Redis repository. Handles the transaction lifecycle. + PerformTx(ctx context.Context, action func(txid uint) error) error + TxModifyOrder(ctx context.Context, txid uint, operation models.Operation, order models.Order) error + TxModifyPrices(ctx context.Context, txid uint, operation models.Operation, order models.Order) error + TxModifyClientOId(ctx context.Context, txid uint, operation models.Operation, order models.Order) error + TxModifyUserOpenOrders(ctx context.Context, txid uint, operation models.Operation, order models.Order) error + TxModifyUserFilledOrders(ctx context.Context, txid uint, operation models.Operation, order models.Order) error + // ------------------------------ // LH side GetMinAsk(ctx context.Context, symbol models.Symbol) models.OrderIter GetMaxBid(ctx context.Context, symbol models.Symbol) models.OrderIter diff --git a/e2e/tests/conftest.py b/e2e/tests/conftest.py new file mode 100644 index 0000000..d2a60d7 --- /dev/null +++ b/e2e/tests/conftest.py @@ -0,0 +1,60 @@ +import os + +import pytest +from orbs_orderbook import CreateOrderInput, OrderBookSDK, OrderSigner +from orbs_orderbook.exceptions import ErrApiRequest + +BASE_URL = os.environ.get("BASE_URL", "http://localhost") +PRIVATE_KEY = os.environ.get( + "PRIVATE_KEY", "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) +API_KEY = os.environ["API_KEY"] + +CLIENT_OID = "550e8400-e29b-41d4-a716-446655440000" +SYMBOL = "MATIC-USDC" +PRICE = "0.86500000" +SIZE = "40" + + +@pytest.fixture +def ob_client(): + print("HI") + yield OrderBookSDK(base_url=BASE_URL, api_key=API_KEY) + + +@pytest.fixture +def ob_signer(ob_client): + yield OrderSigner( + private_key=PRIVATE_KEY, + sdk=ob_client, + ) + + +@pytest.fixture(autouse=True, scope="function") +def cancel_all_orders(ob_client, ob_signer): + try: + ob_client.cancel_all_orders() + except Exception: + print("No orders to cancel") + pass + + +@pytest.fixture +def create_new_orders(ob_client, ob_signer, cancel_all_orders): + order_input = CreateOrderInput( + price=PRICE, + size=SIZE, + symbol=SYMBOL, + side="sell", + client_order_id=CLIENT_OID, + ) + + signature, message = ob_signer.prepare_and_sign_order(order_input) + + yield [ + ob_client.create_order( + order_input=order_input, + signature=signature, + message=message, + ) + ] diff --git a/e2e/tests/maker_endpoints_test.py b/e2e/tests/maker_endpoints_test.py index b88a47b..0b4e8a9 100644 --- a/e2e/tests/maker_endpoints_test.py +++ b/e2e/tests/maker_endpoints_test.py @@ -4,72 +4,15 @@ A local Orderbook instance is required to run the tests. """ - -import os - import pytest -from orbs_orderbook import CreateOrderInput, OrderBookSDK, OrderSigner +from orbs_orderbook import CreateOrderInput from orbs_orderbook.exceptions import ErrApiRequest - -BASE_URL = os.environ.get("BASE_URL", "http://localhost") -PRIVATE_KEY = os.environ.get( - "PRIVATE_KEY", "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -) -API_KEY = os.environ["API_KEY"] - -CLIENT_OID = "550e8400-e29b-41d4-a716-446655440000" -SYMBOL = "MATIC-USDC" -PRICE = "0.86500000" -SIZE = "40" - +from conftest import CLIENT_OID, SIZE, SYMBOL, API_KEY # TODO 1: Add test for get market depth # TODO 2: Add different test scenaries (eg. different failed states) -@pytest.fixture -def ob_client(): - yield OrderBookSDK(base_url=BASE_URL, api_key=API_KEY) - - -@pytest.fixture -def ob_signer(ob_client): - yield OrderSigner( - private_key=PRIVATE_KEY, - sdk=ob_client, - ) - - -@pytest.fixture(autouse=True, scope="function") -def cancel_all_orders(ob_client, ob_signer): - try: - ob_client.cancel_all_orders() - except Exception: - print("No orders to cancel") - pass - - -@pytest.fixture -def create_new_orders(ob_client, ob_signer, cancel_all_orders): - order_input = CreateOrderInput( - price=PRICE, - size=SIZE, - symbol=SYMBOL, - side="sell", - client_order_id=CLIENT_OID, - ) - - signature, message = ob_signer.prepare_and_sign_order(order_input) - - yield [ - ob_client.create_order( - order_input=order_input, - signature=signature, - message=message, - ) - ] - - def test_create_order_success(ob_client, ob_signer): order_input = CreateOrderInput( price="0.86500000", diff --git a/e2e/tests/requirements.txt b/e2e/tests/requirements.txt index 874d41f..ca522aa 100644 --- a/e2e/tests/requirements.txt +++ b/e2e/tests/requirements.txt @@ -1,2 +1,3 @@ -orbs-orderbook-sdk==0.9.1 -pytest==7.4.4 \ No newline at end of file +orbs-orderbook-sdk==0.9.2 +pytest==7.4.4 +redis==5.0.3 \ No newline at end of file diff --git a/mocks/service.go b/mocks/service.go index ab3d663..07cb1d9 100644 --- a/mocks/service.go +++ b/mocks/service.go @@ -102,6 +102,31 @@ func (m *MockOrderBookStore) CancelOrdersForUser(ctx context.Context, userId uui return orderIds, m.Error } +// Generic Building blocks with no biz logic in a single TX +func (m *MockOrderBookStore) PerformTx(ctx context.Context, action func(txid uint) error) error { + return m.Error +} + +func (m *MockOrderBookStore) TxModifyOrder(ctx context.Context, txid uint, operation models.Operation, order models.Order) error { + return m.Error +} + +func (m *MockOrderBookStore) TxModifyPrices(ctx context.Context, txid uint, operation models.Operation, order models.Order) error { + return m.Error +} + +func (m *MockOrderBookStore) TxModifyClientOId(ctx context.Context, txid uint, operation models.Operation, order models.Order) error { + return m.Error +} + +func (m *MockOrderBookStore) TxModifyUserOpenOrders(ctx context.Context, txid uint, operation models.Operation, order models.Order) error { + return m.Error +} + +func (m *MockOrderBookStore) TxModifyUserFilledOrders(ctx context.Context, txid uint, operation models.Operation, order models.Order) error { + return m.Error +} + func (m *MockOrderBookStore) GetUserByPublicKey(ctx context.Context, publicKey string) (*models.User, error) { if m.Error != nil { return nil, m.Error diff --git a/mocks/transport.go b/mocks/transport.go index 3ac6bf6..a31997c 100644 --- a/mocks/transport.go +++ b/mocks/transport.go @@ -29,7 +29,7 @@ func (m *MockOrderBookService) CreateOrder(ctx context.Context, input service.Cr return *m.Order, m.Error } -func (m *MockOrderBookService) CancelOrder(ctx context.Context, input service.CancelOrderInput) (cancelledOrderId *uuid.UUID, err error) { +func (m *MockOrderBookService) CancelOrder(ctx context.Context, input service.CancelOrderInput) (*uuid.UUID, error) { if m.Error != nil { return nil, m.Error } diff --git a/models/crud.go b/models/crud.go new file mode 100644 index 0000000..1fc9350 --- /dev/null +++ b/models/crud.go @@ -0,0 +1,9 @@ +package models + +type Operation int + +const ( + Add Operation = iota + Remove + Update +) diff --git a/models/errors.go b/models/errors.go index b252278..e622ade 100644 --- a/models/errors.go +++ b/models/errors.go @@ -3,6 +3,7 @@ package models import "errors" var ErrNotFound = errors.New("entity not found") +var ErrUnsupportedOperation = errors.New("unsupported operation") var ErrClashingOrderId = errors.New("order already exists") var ErrClashingClientOrderId = errors.New("order already exists") var ErrUnexpectedError = errors.New("unexpected error") diff --git a/models/order.go b/models/order.go index 0d2991d..c19589d 100644 --- a/models/order.go +++ b/models/order.go @@ -219,7 +219,7 @@ func (o *Order) IsFilled() bool { } func (o *Order) IsUnfilled() bool { - return o.SizeFilled.IsZero() && o.SizePending.IsZero() + return o.SizeFilled.IsZero() } func (o *Order) IsPartialFilled() bool { diff --git a/models/order_test.go b/models/order_test.go index 7725e5d..d14f12f 100644 --- a/models/order_test.go +++ b/models/order_test.go @@ -350,32 +350,14 @@ func TestOrder_IsUnfilled(t *testing.T) { { name: "sizeFilled 0, sizePending 0", order: Order{ - SizeFilled: decimal.Zero, - SizePending: decimal.Zero, + SizeFilled: decimal.Zero, }, expected: true, }, { name: "sizeFilled 1000, sizePending 0", order: Order{ - SizeFilled: decimal.NewFromInt(1000), - SizePending: decimal.Zero, - }, - expected: false, - }, - { - name: "sizeFilled 0, sizePending 1000", - order: Order{ - SizeFilled: decimal.Zero, - SizePending: decimal.NewFromInt(1000), - }, - expected: false, - }, - { - name: "sizeFilled 1000, sizePending 1000", - order: Order{ - SizeFilled: decimal.NewFromInt(1000), - SizePending: decimal.NewFromInt(1000), + SizeFilled: decimal.NewFromInt(1000), }, expected: false, }, diff --git a/service/cancel_order.go b/service/cancel_order.go index 5563331..c9d95c7 100644 --- a/service/cancel_order.go +++ b/service/cancel_order.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "github.com/google/uuid" "github.com/orbs-network/order-book/models" @@ -15,8 +16,10 @@ type CancelOrderInput struct { UserId uuid.UUID } -// CancelOrder cancels an order by its ID or clientOId. If `isClientOId` is true, the `id` is treated as a clientOinput.Id, otherwise it is treated as an orderId. -func (s *Service) CancelOrder(ctx context.Context, input CancelOrderInput) (cancelledOrderId *uuid.UUID, err error) { +// Flow chart - https://miro.com/welcomeonboard/Umt0YnpDN3BEcUh1U0JZaHNpejJNUHV3QmpBTGpTNFdybXVlemk2QlV4RHAwc2xVSXR5VzM0NzJwUlhGZEFRMnwzMDc0NDU3MzU4MzEyODA0NjQ2fDI=?share_link_id=23847173917 + +// CancelOrder cancels an order by its ID or clientOId. If `isClientOId` is true, the `id` is treated as a clientOinput.Id, otherwise it is treated as an orderId +func (s *Service) CancelOrder(ctx context.Context, input CancelOrderInput) (*uuid.UUID, error) { order, err := s.getOrder(ctx, input.IsClientOId, input.Id) if err != nil { @@ -28,42 +31,78 @@ func (s *Service) CancelOrder(ctx context.Context, input CancelOrderInput) (canc return nil, models.ErrNotFound } - if order.IsPending() { - logctx.Info(ctx, "cancelling a pending order", logger.String("orderId", order.Id.String()), logger.String("sizePending", order.SizePending.String())) - err = s.orderBookStore.CancelPendingOrder(ctx, *order) - if err != nil { - logctx.Error(ctx, "error CancelPendingOrder", logger.Error(err)) - return nil, err - } - return nil, models.ErrOrderPending + if order.Cancelled { + logctx.Warn(ctx, "order already cancelled", logger.String("orderId", order.Id.String())) + return nil, models.ErrOrderCancelled } if order.IsFilled() { - logctx.Warn(ctx, "cancelling order not possible when order is filled", logger.String("orderId", order.Id.String()), logger.String("sizeFilled", order.SizeFilled.String()), logger.String("size", order.Size.String())) + logctx.Warn(ctx, "order already filled", logger.String("orderId", order.Id.String())) return nil, models.ErrOrderFilled } - if order.IsUnfilled() { - err = s.orderBookStore.CancelUnfilledOrder(ctx, *order) - if err != nil { - logctx.Error(ctx, "error CancelUnfilledOrder", logger.Error(err)) - return nil, err + err = s.orderBookStore.PerformTx(ctx, func(txid uint) error { + order.Cancelled = true + + // remove from prices + if err := s.orderBookStore.TxModifyPrices(ctx, txid, models.Remove, *order); err != nil { + logctx.Error(ctx, "Failed removing order from prices", logger.String("id", input.Id.String()), logger.String("side", order.Side.String()), logger.Error(err)) + return fmt.Errorf("failed removing order from prices: %w", err) } - logctx.Info(ctx, "unfilled order removed", logger.String("orderId", order.Id.String()), logger.String("userId", order.UserId.String()), logger.String("size", order.Size.String()), logger.String("sizeFilled", order.SizeFilled.String()), logger.String("sizePending", order.SizePending.String())) + // remove from user's open orders + if err = s.orderBookStore.TxModifyUserOpenOrders(ctx, txid, models.Remove, *order); err != nil { + logctx.Error(ctx, "Failed removing order from user open orders", logger.String("id", input.Id.String()), logger.String("userId", order.UserId.String()), logger.Error(err)) + return fmt.Errorf("failed removing order from user open orders: %w", err) + } - return &order.Id, nil - } else { - err = s.orderBookStore.CancelPartialFilledOrder(ctx, *order) - if err != nil { - logctx.Error(ctx, "error occured when cancelling partial order", logger.Error(err)) - return nil, err + switch { + // ORDER IS PARTIALLY FILLED AND NOT PENDING + case !order.IsUnfilled() && !order.IsPending(): + logctx.Debug(ctx, "cancelling partially filled and not pending order", logger.String("orderId", order.Id.String())) + if err := s.orderBookStore.TxModifyUserFilledOrders(ctx, txid, models.Add, *order); err != nil { + logctx.Error(ctx, "Failed adding order to user filled orders", logger.String("id", input.Id.String()), logger.String("userId", order.UserId.String()), logger.Error(err)) + return fmt.Errorf("failed adding order to user filled orders: %w", err) + } + if err = s.orderBookStore.TxModifyOrder(ctx, txid, models.Update, *order); err != nil { + logctx.Error(ctx, "Failed updating order to cancelled", logger.String("id", input.Id.String()), logger.Error(err)) + return fmt.Errorf("failed updating order to cancelled: %w", err) + } + // ORDER IS PARTIALLY FILLED AND PENDING + case !order.IsUnfilled() && order.IsPending(): + logctx.Debug(ctx, "cancelling partially filled and pending order", logger.String("orderId", order.Id.String())) + if err = s.orderBookStore.TxModifyOrder(ctx, txid, models.Update, *order); err != nil { + logctx.Error(ctx, "Failed updating order", logger.String("id", input.Id.String()), logger.Error(err)) + return fmt.Errorf("failed updating order: %w", err) + } + // ORDER IS UNFILLED AND NOT PENDING + case order.IsUnfilled() && !order.IsPending(): + logctx.Debug(ctx, "cancelling unfilled and not pending order", logger.String("orderId", order.Id.String())) + if err := s.orderBookStore.TxModifyClientOId(ctx, txid, models.Remove, *order); err != nil { + logctx.Error(ctx, "Failed removing order from clientOId", logger.String("id", input.Id.String()), logger.Error(err)) + return fmt.Errorf("failed removing unfilled order: %w", err) + } + if err = s.orderBookStore.TxModifyOrder(ctx, txid, models.Remove, *order); err != nil { + logctx.Error(ctx, "Failed removing order", logger.String("id", input.Id.String()), logger.Error(err)) + return fmt.Errorf("failed removing unfilled order: %w", err) + } + // ORDER IS UNFILLED AND PENDING + case order.IsUnfilled() && order.IsPending(): + logctx.Debug(ctx, "cancelling unfilled and pending order", logger.String("orderId", order.Id.String())) + if err = s.orderBookStore.TxModifyOrder(ctx, txid, models.Update, *order); err != nil { + logctx.Error(ctx, "Failed updating order", logger.String("id", input.Id.String()), logger.Error(err)) + return fmt.Errorf("failed updating order: %w", err) + } + default: + logctx.Error(ctx, "unexpected order state", logger.String("orderId", order.Id.String()), logger.String("size", order.Size.String()), logger.String("sizeFilled", order.SizeFilled.String()), logger.String("sizePending", order.SizePending.String())) + return models.ErrUnexpectedError } - logctx.Info(ctx, "partial filled order cancelled", logger.String("orderId", order.Id.String()), logger.String("userId", order.UserId.String()), logger.String("size", order.Size.String()), logger.String("sizeFilled", order.SizeFilled.String()), logger.String("sizePending", order.SizePending.String())) + return nil + }) - return &order.Id, nil - } + logctx.Debug(ctx, "order cancelled", logger.String("orderId", order.Id.String()), logger.String("userId", order.UserId.String()), logger.String("size", order.Size.String()), logger.String("sizeFilled", order.SizeFilled.String()), logger.String("sizePending", order.SizePending.String())) + return &order.Id, nil } func (s *Service) getOrder(ctx context.Context, isClientOId bool, orderId uuid.UUID) (order *models.Order, err error) { diff --git a/service/cancel_order_test.go b/service/cancel_order_test.go index c582b67..b1a7aea 100644 --- a/service/cancel_order_test.go +++ b/service/cancel_order_test.go @@ -33,11 +33,12 @@ func TestService_CancelOrder(t *testing.T) { {name: "unexpected error when finding order by orderId - returns error", isClientOId: false, err: assert.AnError, expectedOrderId: nil, expectedErr: assert.AnError}, {name: "unexpected error when finding order by clientOId - returns error", isClientOId: true, err: assert.AnError, expectedOrderId: nil, expectedErr: assert.AnError}, {name: "order not found - returns `ErrNotFound` error", isClientOId: false, order: nil, err: nil, expectedOrderId: nil, expectedErr: models.ErrNotFound}, - {name: "cancelling order not possible when order is pending", isClientOId: false, order: &models.Order{UserId: userId, Size: decimal.NewFromInt(9999999), SizePending: decimal.NewFromFloat(254), SizeFilled: decimal.NewFromFloat(32323.32)}, expectedOrderId: nil, expectedErr: models.ErrOrderPending}, - {name: "cancelling order not possible when order is filled", isClientOId: false, order: &models.Order{UserId: userId, SizePending: decimal.NewFromFloat(0), SizeFilled: decimal.NewFromFloat(99999.99), Size: decimal.NewFromFloat(99999.99)}, expectedOrderId: nil, expectedErr: models.ErrOrderFilled}, - {name: "unexpected error when removing order - returns error", isClientOId: false, order: order, err: assert.AnError, expectedOrderId: nil, expectedErr: assert.AnError}, - {name: "order removed successfully by orderId - returns cancelled orderId", isClientOId: false, order: order, err: nil, expectedOrderId: &orderId, expectedErr: nil}, - {name: "order removed successfully by clientOId - returns cancelled orderId", isClientOId: true, order: order, err: nil, expectedOrderId: &orderId, expectedErr: nil}, + {name: "order already cancelled - returns `ErrOrderCancelled` error", isClientOId: false, order: &models.Order{Cancelled: true}, expectedOrderId: nil, expectedErr: models.ErrOrderCancelled}, + {name: "order already filled so cannot be cancelled - returns `ErrOrderFilled`", isClientOId: false, order: &models.Order{UserId: userId, SizeFilled: decimal.NewFromFloat(99999.99), Size: decimal.NewFromFloat(99999.99)}, expectedOrderId: nil, expectedErr: models.ErrOrderFilled}, + {name: "order is partially filled and not pending", isClientOId: false, order: &models.Order{Id: order.Id, UserId: userId, SizeFilled: decimal.NewFromFloat(10), Size: decimal.NewFromFloat(50), SizePending: decimal.NewFromFloat(0)}, expectedOrderId: &order.Id, expectedErr: nil}, + {name: "order is partially filled and pending", isClientOId: false, order: &models.Order{Id: order.Id, UserId: userId, SizeFilled: decimal.NewFromFloat(10), Size: decimal.NewFromFloat(50), SizePending: decimal.NewFromFloat(40)}, expectedOrderId: &order.Id, expectedErr: nil}, + {name: "order is not filled and not pending", isClientOId: false, order: &models.Order{Id: order.Id, UserId: userId, SizeFilled: decimal.NewFromFloat(0), Size: decimal.NewFromFloat(50), SizePending: decimal.NewFromFloat(0)}, expectedOrderId: &order.Id, expectedErr: nil}, + {name: "order is not filled and pending", isClientOId: false, order: &models.Order{Id: order.Id, UserId: userId, SizeFilled: decimal.NewFromFloat(0), Size: decimal.NewFromFloat(50), SizePending: decimal.NewFromFloat(40)}, expectedOrderId: &order.Id, expectedErr: nil}, } for _, c := range testCases { diff --git a/service/service.go b/service/service.go index 1141e09..37c20e9 100644 --- a/service/service.go +++ b/service/service.go @@ -14,7 +14,7 @@ import ( type OrderBookService interface { CreateOrder(ctx context.Context, input CreateOrderInput) (models.Order, error) - CancelOrder(ctx context.Context, input CancelOrderInput) (cancelledOrderId *uuid.UUID, err error) + CancelOrder(ctx context.Context, input CancelOrderInput) (*uuid.UUID, error) GetOrderById(ctx context.Context, orderId uuid.UUID) (*models.Order, error) GetOrderByClientOId(ctx context.Context, clientOId uuid.UUID) (*models.Order, error) GetMarketDepth(ctx context.Context, symbol models.Symbol, depth int) (models.MarketDepth, error) diff --git a/transport/rest/cancel_order.go b/transport/rest/cancel_order.go index c9b0a52..e32a193 100644 --- a/transport/rest/cancel_order.go +++ b/transport/rest/cancel_order.go @@ -106,6 +106,12 @@ func (h *Handler) handleCancelOrder(input hInput) { return } + if err == models.ErrOrderCancelled { + logctx.Warn(input.ctx, "order already cancelled", logger.String("id", input.id.String())) + restutils.WriteJSONError(input.ctx, input.w, http.StatusConflict, "Order already cancelled") + return + } + if err == models.ErrOrderPending { logctx.Warn(input.ctx, "order is cancelled but part of it is pending (locked)", logger.String("id", input.id.String())) restutils.WriteJSONError(input.ctx, input.w, http.StatusConflict, "order is cancelled but some of it's size is pending") diff --git a/transport/rest/cancel_order_test.go b/transport/rest/cancel_order_test.go index b85df45..cf28a0d 100644 --- a/transport/rest/cancel_order_test.go +++ b/transport/rest/cancel_order_test.go @@ -76,6 +76,7 @@ func TestHandler_CancelOrderByOrderId(t *testing.T) { http.StatusNotFound, "{\"status\":404,\"msg\":\"Order not found\"}\n", }, + {"order already cancelled", &mocks.MockOrderBookService{Error: models.ErrOrderCancelled}, fmt.Sprintf("/order/%s", orderId.String()), http.StatusConflict, "{\"status\":409,\"msg\":\"Order already cancelled\"}\n"}, { "unexpected error from service", &mocks.MockOrderBookService{Error: assert.AnError}, diff --git a/transport/rest/taker.go b/transport/rest/taker.go index 08676f2..0b0cfd3 100644 --- a/transport/rest/taker.go +++ b/transport/rest/taker.go @@ -163,7 +163,11 @@ func (h *Handler) handleQuote(w http.ResponseWriter, r *http.Request, isSwap boo side := pair.GetSide(req.InToken) svcQuoteRes, err := h.svc.GetQuote(r.Context(), pair.Symbol(), side, inAmount, minOutAmount) if err != nil { - restutils.WriteJSONError(ctx, w, http.StatusInternalServerError, err.Error()) + if err == models.ErrMinOutAmount { + restutils.WriteJSONError(ctx, w, http.StatusBadRequest, err.Error()) + } else { + restutils.WriteJSONError(ctx, w, http.StatusInternalServerError, err.Error()) + } return nil }