diff --git a/indexer/packages/redis/__tests__/caches/state-filled-quantums-cache.test.ts b/indexer/packages/redis/__tests__/caches/state-filled-quantums-cache.test.ts new file mode 100644 index 00000000000..5031f40abd7 --- /dev/null +++ b/indexer/packages/redis/__tests__/caches/state-filled-quantums-cache.test.ts @@ -0,0 +1,45 @@ +import { deleteAllAsync, ttl } from '../../src/helpers/redis'; +import { redis as client } from '../helpers/utils'; +import { orderId } from './constants'; +import { OrderTable } from '@dydxprotocol-indexer/postgres'; +import { + STATE_FILLED_QUANTUMS_TTL_SECONDS, + getCacheKey, + getStateFilledQuantums, + updateStateFilledQuantums, +} from '../../src/caches/state-filled-quantums-cache'; + +describe('stateFilledQuantumsCache', () => { + const orderUuid: string = OrderTable.orderIdToUuid(orderId); + + beforeEach(async () => { + await deleteAllAsync(client); + }); + + afterEach(async () => { + await deleteAllAsync(client); + }); + + describe('updateStateFilledQuantums', () => { + it('updates the state filled amount for an order id', async () => { + const filledQuantums: string = '1000'; + await updateStateFilledQuantums(orderUuid, filledQuantums, client); + + expect(await getStateFilledQuantums(orderUuid, client)).toEqual(filledQuantums); + expect(await ttl(client, getCacheKey(orderUuid))).toEqual(STATE_FILLED_QUANTUMS_TTL_SECONDS); + }); + }); + + describe('getStateFilledQuantums', () => { + it('gets the state filled amount for an order id', async () => { + const filledQuantums: string = '1000'; + await updateStateFilledQuantums(orderUuid, filledQuantums, client); + + expect(await getStateFilledQuantums(orderUuid, client)).toEqual(filledQuantums); + }); + + it('returns undefined if order id does not exist', async () => { + expect(await getStateFilledQuantums(orderUuid, client)).toEqual(undefined); + }); + }); +}); diff --git a/indexer/packages/redis/src/caches/state-filled-quantums-cache.ts b/indexer/packages/redis/src/caches/state-filled-quantums-cache.ts new file mode 100644 index 00000000000..510f91a918b --- /dev/null +++ b/indexer/packages/redis/src/caches/state-filled-quantums-cache.ts @@ -0,0 +1,52 @@ +import { RedisClient } from 'redis'; + +import { getAsync, setexAsync } from '../helpers/redis'; + +export const STATE_FILLED_QUANTUMS_CACHE_KEY_PREFIX: string = 'v4/state_filled_quantums/'; +export const STATE_FILLED_QUANTUMS_TTL_SECONDS: number = 300; // 5 minutes + +/** + * Updates the state-filled quantums for an order id. This is the total filled quantums of the order + * in the state of the network. + * @param orderId + * @param filledQuantums + * @param client + */ +export async function updateStateFilledQuantums( + orderId: string, + filledQuantums: string, + client: RedisClient, +): Promise { + await setexAsync({ + key: getCacheKey(orderId), + value: filledQuantums, + timeToLiveSeconds: STATE_FILLED_QUANTUMS_TTL_SECONDS, + }, client); +} + +/** + * Gets the state-filled quantums for an order id. This is the total filled quantums of the order + * in the state of the network. + * @param orderId + * @param client + * @returns + */ +export async function getStateFilledQuantums( + orderId: string, + client: RedisClient, +): Promise { + const filledQuantums: string | null = await getAsync( + getCacheKey(orderId), + client, + ); + + if (filledQuantums === null) { + return undefined; + } + + return filledQuantums; +} + +export function getCacheKey(orderId: string): string { + return `${STATE_FILLED_QUANTUMS_CACHE_KEY_PREFIX}${orderId}`; +} diff --git a/indexer/packages/redis/src/index.ts b/indexer/packages/redis/src/index.ts index c1b716f7261..de661b0e0a3 100644 --- a/indexer/packages/redis/src/index.ts +++ b/indexer/packages/redis/src/index.ts @@ -10,6 +10,7 @@ export * as OrderbookLevelsCache from './caches/orderbook-levels-cache'; export * as LatestAccountPnlTicksCache from './caches/latest-account-pnl-ticks-cache'; export * as CanceledOrdersCache from './caches/canceled-orders-cache'; export * as StatefulOrderUpdatesCache from './caches/stateful-order-updates-cache'; +export * as StateFilledQuantumsCache from './caches/state-filled-quantums-cache'; export { placeOrder } from './caches/place-order'; export { removeOrder } from './caches/remove-order'; export { updateOrder } from './caches/update-order'; diff --git a/indexer/services/ender/__tests__/handlers/order-fills/liquidation-handler.test.ts b/indexer/services/ender/__tests__/handlers/order-fills/liquidation-handler.test.ts index c86aa123b47..0832618c9fe 100644 --- a/indexer/services/ender/__tests__/handlers/order-fills/liquidation-handler.test.ts +++ b/indexer/services/ender/__tests__/handlers/order-fills/liquidation-handler.test.ts @@ -74,6 +74,7 @@ import { clearCandlesMap } from '../../../src/caches/candle-cache'; import Long from 'long'; import { createPostgresFunctions } from '../../../src/helpers/postgres/postgres-functions'; import config from '../../../src/config'; +import { expectStateFilledQuantums } from '../../helpers/redis-helpers'; const defaultClobPairId: string = testConstants.defaultPerpetualMarket.clobPairId; const defaultMakerFeeQuantum: number = 1_000_000; @@ -424,6 +425,10 @@ describe('LiquidationHandler', () => { exitPrice: makerPrice, }, ), + expectStateFilledQuantums( + OrderTable.orderIdToUuid(makerOrderProto.orderId!), + orderFillEvent.totalFilledMaker.toString(), + ), expectCandlesUpdated(), ]); @@ -666,6 +671,10 @@ describe('LiquidationHandler', () => { eventId, ), expectCandlesUpdated(), + expectStateFilledQuantums( + OrderTable.orderIdToUuid(makerOrderProto.orderId!), + orderFillEvent.totalFilledMaker.toString(), + ), ]); if (!useSqlFunction) { @@ -827,6 +836,10 @@ describe('LiquidationHandler', () => { eventId, ), expectCandlesUpdated(), + expectStateFilledQuantums( + OrderTable.orderIdToUuid(makerOrderProto.orderId!), + orderFillEvent.totalFilledMaker.toString(), + ), ]); }); diff --git a/indexer/services/ender/__tests__/handlers/order-fills/order-handler.test.ts b/indexer/services/ender/__tests__/handlers/order-fills/order-handler.test.ts index 99a5e7a78de..e8f49aeab4d 100644 --- a/indexer/services/ender/__tests__/handlers/order-fills/order-handler.test.ts +++ b/indexer/services/ender/__tests__/handlers/order-fills/order-handler.test.ts @@ -75,6 +75,7 @@ import Long from 'long'; import { createPostgresFunctions } from '../../../src/helpers/postgres/postgres-functions'; import config from '../../../src/config'; import { redisClient } from '../../../src/helpers/redis/redis-controller'; +import { expectStateFilledQuantums } from '../../helpers/redis-helpers'; const defaultClobPairId: string = testConstants.defaultPerpetualMarket.clobPairId; const defaultMakerFeeQuantum: number = 1_000_000; @@ -479,6 +480,14 @@ describe('OrderHandler', () => { }, ), expectCandlesUpdated(), + expectStateFilledQuantums( + OrderTable.orderIdToUuid(makerOrderProto.orderId!), + orderFillEvent.totalFilledMaker.toString(), + ), + expectStateFilledQuantums( + OrderTable.orderIdToUuid(takerOrderProto.orderId!), + orderFillEvent.totalFilledTaker.toString(), + ), ]); if (!useSqlFunction) { @@ -833,6 +842,14 @@ describe('OrderHandler', () => { eventId, ), expectCandlesUpdated(), + expectStateFilledQuantums( + OrderTable.orderIdToUuid(makerOrderProto.orderId!), + orderFillEvent.totalFilledMaker.toString(), + ), + expectStateFilledQuantums( + OrderTable.orderIdToUuid(takerOrderProto.orderId!), + orderFillEvent.totalFilledTaker.toString(), + ), ]); if (!useSqlFunction) { @@ -1042,6 +1059,14 @@ describe('OrderHandler', () => { eventId, ), expectCandlesUpdated(), + expectStateFilledQuantums( + OrderTable.orderIdToUuid(makerOrderProto.orderId!), + orderFillEvent.totalFilledMaker.toString(), + ), + expectStateFilledQuantums( + OrderTable.orderIdToUuid(takerOrderProto.orderId!), + orderFillEvent.totalFilledTaker.toString(), + ), ]); }); @@ -1253,6 +1278,14 @@ describe('OrderHandler', () => { eventId, ), expectCandlesUpdated(), + expectStateFilledQuantums( + OrderTable.orderIdToUuid(makerOrderProto.orderId!), + orderFillEvent.totalFilledMaker.toString(), + ), + expectStateFilledQuantums( + OrderTable.orderIdToUuid(takerOrderProto.orderId!), + orderFillEvent.totalFilledTaker.toString(), + ), ]); }); diff --git a/indexer/services/ender/__tests__/helpers/redis-helpers.ts b/indexer/services/ender/__tests__/helpers/redis-helpers.ts index 7002d8b79cc..3af8f0c1f0a 100644 --- a/indexer/services/ender/__tests__/helpers/redis-helpers.ts +++ b/indexer/services/ender/__tests__/helpers/redis-helpers.ts @@ -1,4 +1,7 @@ -import { NextFundingCache } from '@dydxprotocol-indexer/redis'; +import { + NextFundingCache, + StateFilledQuantumsCache, +} from '@dydxprotocol-indexer/redis'; import Big from 'big.js'; import { redisClient } from '../../src/helpers/redis/redis-controller'; @@ -13,3 +16,16 @@ export async function expectNextFundingRate( ); expect(rates[ticker]).toEqual(rate); } + +export async function expectStateFilledQuantums( + orderUuid: string, + quantums: string, +): Promise { + const stateFilledQuantums: string | undefined = await StateFilledQuantumsCache + .getStateFilledQuantums( + orderUuid, + redisClient, + ); + expect(stateFilledQuantums).toBeDefined(); + expect(stateFilledQuantums).toEqual(quantums); +} diff --git a/indexer/services/ender/src/handlers/order-fills/liquidation-handler.ts b/indexer/services/ender/src/handlers/order-fills/liquidation-handler.ts index 4e5ca2d4250..203dac7ee82 100644 --- a/indexer/services/ender/src/handlers/order-fills/liquidation-handler.ts +++ b/indexer/services/ender/src/handlers/order-fills/liquidation-handler.ts @@ -16,7 +16,7 @@ import { USDC_ASSET_ID, OrderStatus, FillType, } from '@dydxprotocol-indexer/postgres'; -import { CanceledOrderStatus } from '@dydxprotocol-indexer/redis'; +import { CanceledOrderStatus, StateFilledQuantumsCache } from '@dydxprotocol-indexer/redis'; import { isStatefulOrder } from '@dydxprotocol-indexer/v4-proto-parser'; import { LiquidationOrderV1, IndexerOrderId, OrderFillEventV1, @@ -27,6 +27,7 @@ import * as pg from 'pg'; import config from '../../config'; import { STATEFUL_ORDER_ORDER_FILL_EVENT_TYPE, SUBACCOUNT_ORDER_FILL_EVENT_TYPE } from '../../constants'; import { convertPerpetualPosition } from '../../helpers/kafka-helper'; +import { redisClient } from '../../helpers/redis/redis-controller'; import { orderFillWithLiquidityToOrderFillEventWithLiquidation, } from '../../helpers/translation-helper'; @@ -136,6 +137,13 @@ export class LiquidationHandler extends AbstractOrderFillHandler { [ 'goodTilBlock', redisTestConstants.defaultOrderId, - testConstants.defaultOrder, + { + ...testConstants.defaultOrder, + status: OrderStatus.FILLED, + }, redisTestConstants.defaultRedisOrder, redisTestConstants.defaultOrderUuid, ], [ 'goodTilBlockTime', redisTestConstants.defaultOrderIdGoodTilBlockTime, - testConstants.defaultOrderGoodTilBlockTime, + { + ...testConstants.defaultOrderGoodTilBlockTime, + status: OrderStatus.FILLED, + }, redisTestConstants.defaultRedisOrderGoodTilBlockTime, redisTestConstants.defaultOrderUuidGoodTilBlockTime, ], [ 'conditional', redisTestConstants.defaultOrderIdConditional, - testConstants.defaultConditionalOrder, + { + ...testConstants.defaultConditionalOrder, + status: OrderStatus.FILLED, + }, redisTestConstants.defaultRedisOrderConditional, redisTestConstants.defaultOrderUuidConditional, ], ])( - 'does not send subaccount message for fully-filled orders for best effort user cancel ' + - '(with %s)', + 'does not send subaccount message for orders fully-filled in state for best effort ' + + 'user cancel (with %s)', async ( _name: string, removedOrderId: IndexerOrderId, @@ -813,6 +823,11 @@ describe('OrderRemoveHandler', () => { sizeDeltaInQuantums: defaultQuantums.toString(), client: redisClient, }), + StateFilledQuantumsCache.updateStateFilledQuantums( + expectedOrderUuid, + removedRedisOrder.order!.quantums.toString(), + redisClient, + ), ]); const fullyFilledUpdate: redisTestConstants.OffChainUpdateOrderUpdateUpdateMessage = { @@ -838,7 +853,7 @@ describe('OrderRemoveHandler', () => { await orderRemoveHandler.handleUpdate(offChainUpdate); await Promise.all([ - expectOrderStatus(expectedOrderUuid, OrderStatus.BEST_EFFORT_CANCELED), + expectOrderStatus(expectedOrderUuid, removedOrder.status), // orderbook should not be affected, so it will be set to defaultQuantums expectOrderbookLevelCache( removedRedisOrder.ticker, @@ -855,7 +870,7 @@ describe('OrderRemoveHandler', () => { // no orderbook message because no change in orderbook levels expectNoWebsocketMessagesSent(producerSendSpy); expect(logger.error).not.toHaveBeenCalled(); - expectTimingStats(true, true); + expectTimingStats(true, false); }, ); @@ -1553,7 +1568,10 @@ describe('OrderRemoveHandler', () => { it('successfully removes fully filled expired order and does not send websocket message', async () => { const removedOrderId: IndexerOrderId = redisTestConstants.defaultOrderId; - const removedOrder: OrderCreateObject = indexerExpiredDefaultOrder; + const removedOrder: OrderCreateObject = { + ...indexerExpiredDefaultOrder, + status: OrderStatus.FILLED, + }; const removedRedisOrder: RedisOrder = redisTestConstants.defaultRedisOrder; const expectedOrderUuid: string = redisTestConstants.defaultOrderUuid; @@ -1574,6 +1592,11 @@ describe('OrderRemoveHandler', () => { sizeDeltaInQuantums: orderbookLevel, client: redisClient, }), + StateFilledQuantumsCache.updateStateFilledQuantums( + expectedOrderUuid, + removedRedisOrder.order!.quantums.toString(), + redisClient, + ), ]); await Promise.all([ @@ -1600,7 +1623,7 @@ describe('OrderRemoveHandler', () => { orderbookLevel, ).toString(); await Promise.all([ - expectOrderStatus(expectedOrderUuid, OrderStatus.CANCELED), + expectOrderStatus(expectedOrderUuid, removedOrder.status), expectOrderbookLevelCache( removedRedisOrder.ticker, OrderSide.BUY, @@ -1613,7 +1636,7 @@ describe('OrderRemoveHandler', () => { expectCanceledOrderStatus(expectedOrderUuid, CanceledOrderStatus.CANCELED), ]); expectNoWebsocketMessagesSent(producerSendSpy); - expectTimingStats(true, true); + expectTimingStats(true, false); }); it('error: when latest block not found, log and exit', async () => { diff --git a/indexer/services/vulcan/src/handlers/helpers.ts b/indexer/services/vulcan/src/handlers/helpers.ts index 13639535fbc..156b79119ad 100644 --- a/indexer/services/vulcan/src/handlers/helpers.ts +++ b/indexer/services/vulcan/src/handlers/helpers.ts @@ -4,6 +4,7 @@ import { protocolTranslations, } from '@dydxprotocol-indexer/postgres'; import { subticksToPrice } from '@dydxprotocol-indexer/postgres/build/src/lib/protocol-translations'; +import { StateFilledQuantumsCache } from '@dydxprotocol-indexer/redis'; import { IndexerOrder, IndexerOrder_ConditionType, @@ -11,7 +12,9 @@ import { RedisOrder, RedisOrder_TickerType, } from '@dydxprotocol-indexer/v4-protos'; +import Big from 'big.js'; +import { redisClient } from '../helpers/redis/redis-controller'; import { OrderbookSide } from '../lib/types'; /** @@ -65,3 +68,26 @@ export function orderSideToOrderbookSide( ): OrderbookSide { return orderSide === IndexerOrder_Side.SIDE_BUY ? OrderbookSide.BIDS : OrderbookSide.ASKS; } + +/** + * Gets the remaining quantums for an order based on the filled amount of the order in state + * @param order + * @returns + */ +export async function getStateRemainingQuantums( + order: RedisOrder, +): Promise { + const orderQuantums: Big = Big(order.order!.quantums.toString()); + const stateFilledQuantums: Big = convertToBig( + await StateFilledQuantumsCache.getStateFilledQuantums(order.id, redisClient), + ); + return orderQuantums.minus(stateFilledQuantums); +} + +function convertToBig(value: string | undefined) { + if (value === undefined) { + return Big(0); + } else { + return Big(value); + } +} diff --git a/indexer/services/vulcan/src/handlers/order-remove-handler.ts b/indexer/services/vulcan/src/handlers/order-remove-handler.ts index fcf20837aea..78bcd258e86 100644 --- a/indexer/services/vulcan/src/handlers/order-remove-handler.ts +++ b/indexer/services/vulcan/src/handlers/order-remove-handler.ts @@ -39,7 +39,7 @@ import config from '../config'; import { redisClient } from '../helpers/redis/redis-controller'; import { sendMessageWrapper } from '../lib/send-message-helper'; import { Handler } from './handler'; -import { getTriggerPrice } from './helpers'; +import { getStateRemainingQuantums, getTriggerPrice } from './helpers'; /** * Handler for OrderRemove messages. @@ -260,6 +260,9 @@ export class OrderRemoveHandler extends Handler { return; } + const stateRemainingQuantums: Big = await getStateRemainingQuantums( + removeOrderResult.removedOrder!, + ); const perpetualMarket: PerpetualMarketFromDatabase | undefined = perpetualMarketRefresher .getPerpetualMarketFromTicker(removeOrderResult.removedOrder!.ticker); if (perpetualMarket === undefined) { @@ -271,10 +274,14 @@ export class OrderRemoveHandler extends Handler { return; } - await runFuncWithTimingStat( - this.cancelOrderInPostgres(orderRemove), - this.generateTimingStatsOptions('cancel_order_in_postgres'), - ); + // If the remaining amount of the order in state is <= 0, the order is filled and + // does not need to have it's status updated + if (stateRemainingQuantums.gt(0)) { + await runFuncWithTimingStat( + this.cancelOrderInPostgres(orderRemove), + this.generateTimingStatsOptions('cancel_order_in_postgres'), + ); + } const subaccountMessage: Message = { value: this.createSubaccountWebsocketMessageFromRemoveOrderResult( @@ -284,8 +291,7 @@ export class OrderRemoveHandler extends Handler { ), }; - // TODO(IND-147): Remove this check once fully-filled orders are removed by ender - if (this.shouldSendSubaccountMessage(orderRemove, removeOrderResult)) { + if (this.shouldSendSubaccountMessage(orderRemove, removeOrderResult, stateRemainingQuantums)) { sendMessageWrapper(subaccountMessage, KafkaTopics.TO_WEBSOCKETS_SUBACCOUNTS); } @@ -616,21 +622,26 @@ export class OrderRemoveHandler extends Handler { protected shouldSendSubaccountMessage( orderRemove: OrderRemoveV1, removeOrderResult: RemoveOrderResult, + stateRemainingQuantums: Big, ): boolean { - const remainingQuantums: Big = Big(this.getSizeDeltaInQuantums( - removeOrderResult, - removeOrderResult.removedOrder!, - )); const status: OrderRemoveV1_OrderRemovalStatus = orderRemove.removalStatus; const reason: OrderRemovalReason = orderRemove.reason; + + logger.info({ + at: 'orderRemoveHandler#shouldSendSubaccountMessage', + message: 'Compared state filled quantums and size', + stateRemainingQuantums: stateRemainingQuantums.toFixed(), + removeOrderResult, + }); + if ( - remainingQuantums.eq(0) && + stateRemainingQuantums.lte(0) && status === OrderRemoveV1_OrderRemovalStatus.ORDER_REMOVAL_STATUS_BEST_EFFORT_CANCELED && reason === OrderRemovalReason.ORDER_REMOVAL_REASON_USER_CANCELED ) { return false; } else if ( - remainingQuantums.eq(0) && + stateRemainingQuantums.lte(0) && status === OrderRemoveV1_OrderRemovalStatus.ORDER_REMOVAL_STATUS_CANCELED && reason === OrderRemovalReason.ORDER_REMOVAL_REASON_INDEXER_EXPIRED ) {