From 1d605f550f8dd7f66c9334a005b86f24a1df3cb5 Mon Sep 17 00:00:00 2001 From: Cody Born Date: Mon, 27 Jan 2025 11:23:07 -0500 Subject: [PATCH] Reaper 2.0 (#508) * Work in progress * Add unit tests * More tests * Pull expiry status from validation * Set cron to max timeout * Add comments * Fix lint * Lint fix --- bin/stacks/cron-stack.ts | 2 +- lib/crons/gs-reaper.ts | 201 ++++++++++-- lib/handlers/OrderParser.ts | 19 ++ lib/handlers/check-order-status/service.ts | 23 +- lib/util/constants.ts | 17 +- test/integ/crons/gs-reaper.test.ts | 95 ------ test/test-data.ts | 14 +- .../unit/handlers/gs-reaper/gs-reaper.test.ts | 288 ++++++++++++++++++ 8 files changed, 512 insertions(+), 147 deletions(-) create mode 100644 lib/handlers/OrderParser.ts delete mode 100644 test/integ/crons/gs-reaper.test.ts create mode 100644 test/unit/handlers/gs-reaper/gs-reaper.test.ts diff --git a/bin/stacks/cron-stack.ts b/bin/stacks/cron-stack.ts index 2da1814e..4f06c725 100644 --- a/bin/stacks/cron-stack.ts +++ b/bin/stacks/cron-stack.ts @@ -26,7 +26,7 @@ export class CronStack extends cdk.NestedStack { runtime: cdk.aws_lambda.Runtime.NODEJS_18_X, entry: path.join(__dirname, '../../lib/crons/gs-reaper.ts'), handler: 'handler', - timeout: cdk.Duration.minutes(1), + timeout: cdk.Duration.minutes(15), memorySize: 512, bundling: { minify: true, diff --git a/lib/crons/gs-reaper.ts b/lib/crons/gs-reaper.ts index 6fe3e32b..19759445 100644 --- a/lib/crons/gs-reaper.ts +++ b/lib/crons/gs-reaper.ts @@ -1,17 +1,26 @@ import { EventBridgeEvent, ScheduledHandler } from 'aws-lambda' import { DynamoDB } from 'aws-sdk' import { default as bunyan, default as Logger } from 'bunyan' - import { metricScope, MetricsLogger, Unit } from 'aws-embedded-metrics' -import { ORDER_STATUS, UniswapXOrderEntity } from '../entities' -import { BaseOrdersRepository } from '../repositories/base' +import { ORDER_STATUS, SettledAmount, UniswapXOrderEntity } from '../entities' +import { BaseOrdersRepository, QueryResult } from '../repositories/base' import { DutchOrdersRepository } from '../repositories/dutch-orders-repository' -import { DYNAMO_BATCH_WRITE_MAX, ONE_HOUR_IN_SECONDS } from '../util/constants' +import { BLOCK_RANGE, CRON_MAX_ATTEMPTS, DYNAMO_BATCH_WRITE_MAX, OLDEST_BLOCK_BY_CHAIN } from '../util/constants' +import { ethers } from 'ethers' +import { CosignedPriorityOrder, CosignedV2DutchOrder, CosignedV3DutchOrder, DutchOrder, OrderType, OrderValidation, OrderValidator, REACTOR_ADDRESS_MAPPING, UniswapXEventWatcher, UniswapXOrder } from '@uniswap/uniswapx-sdk' +import { parseOrder } from '../handlers/OrderParser' +import { getSettledAmounts } from '../handlers/check-order-status/util' +import { ChainId } from '../util/chain' export const handler: ScheduledHandler = metricScope((metrics) => async (_event: EventBridgeEvent) => { await main(metrics) }) +/** + * The Reaper is a cron job that runs daily and checks for any orphaned orders + * that have been filled, cancelled or expired + * @param metrics - The metrics logger + */ async function main(metrics: MetricsLogger) { metrics.setNamespace('Uniswap') metrics.setDimensions({ Service: 'UniswapXServiceCron' }) @@ -21,37 +30,177 @@ async function main(metrics: MetricsLogger) { level: 'info', }) const repo = DutchOrdersRepository.create(new DynamoDB.DocumentClient()) - await deleteStaleOrders(repo, log, metrics) + const providers = new Map() + for (const chainIdKey of Object.keys(OLDEST_BLOCK_BY_CHAIN)) { + const chainId = Number(chainIdKey) as keyof typeof OLDEST_BLOCK_BY_CHAIN + const rpcURL = process.env[`RPC_${chainId}`] + const provider = new ethers.providers.StaticJsonRpcProvider(rpcURL, chainId) + providers.set(chainId, provider) + } + await cleanupOrphanedOrders(repo, providers, log, metrics) +} + +type OrderUpdate = { + status: ORDER_STATUS, + txHash?: string, + fillBlock?: number, + settledAmounts?: SettledAmount[] } -export async function deleteStaleOrders( +export async function cleanupOrphanedOrders( repo: BaseOrdersRepository, + providers: Map, log: Logger, metrics?: MetricsLogger ): Promise { - let openOrders = await repo.getByOrderStatus(ORDER_STATUS.OPEN, DYNAMO_BATCH_WRITE_MAX) - for (;;) { - // get orderHashes with deadlines more than 1 hour ago and are still 'open' - const staleOrders = openOrders.orders.flatMap((order) => { - if (order.deadline < Date.now() / 1000 - ONE_HOUR_IN_SECONDS) { - return order.orderHash + + for (const chainIdKey of Object.keys(OLDEST_BLOCK_BY_CHAIN)) { + const chainId = Number(chainIdKey) as keyof typeof OLDEST_BLOCK_BY_CHAIN + const provider = providers.get(chainId) + if (!provider) { + log.error(`No provider found for chainId ${chainId}`) + continue + } + + // get a map of all open orders from the database + const parsedOrders = await getParsedOrders(repo, chainId) + const orderUpdates = new Map() + + // Look through events to find if any of the orders have been filled + for (const orderType of Object.keys(REACTOR_ADDRESS_MAPPING[chainId])){ + const reactorAddress = REACTOR_ADDRESS_MAPPING[chainId][orderType as OrderType] + if (!reactorAddress) continue + const watcher = new UniswapXEventWatcher(provider, reactorAddress) + const lastProcessedBlock = await provider.getBlockNumber() + let recentErrors = 0 + const earliestBlock = OLDEST_BLOCK_BY_CHAIN[chainId] + // TODO: Lookback 1.2 days + // const msPerDay = 1000 * 60 * 60 * 24 * 1.2 + // const blocksPerDay = msPerDay / BLOCK_TIME_MS_BY_CHAIN[chainId] + // const earliestBlock = lastProcessedBlock - blocksPerDay + + for (let i = lastProcessedBlock; i > earliestBlock; i -= BLOCK_RANGE) { + let attempts = 0 + while (attempts < CRON_MAX_ATTEMPTS) { + try { + log.info(`Getting fill events for blocks ${i - BLOCK_RANGE} to ${i}`) + const fillEvents = await watcher.getFillEvents(i - BLOCK_RANGE, i) + recentErrors = Math.max(0, recentErrors - 1) + await Promise.all(fillEvents.map(async (e) => { + if (parsedOrders.has(e.orderHash)) { + log.info(`Fill event found for order ${e.orderHash}`) + // Only get fill info when we know there's a matching event in this + // range due to additional RPC calls that are required for fill info + const fillInfo = await watcher.getFillInfo(i - BLOCK_RANGE, i) + const fillEvent = fillInfo.find((f) => f.orderHash === e.orderHash) + if (fillEvent) { + const [tx, block] = await Promise.all([ + provider.getTransaction(fillEvent.txHash), + provider.getBlock(fillEvent.blockNumber), + ]) + const settledAmounts = getSettledAmounts( + fillEvent, + { + timestamp: block.timestamp, + gasPrice: tx.gasPrice, + maxPriorityFeePerGas: tx.maxPriorityFeePerGas, + maxFeePerGas: tx.maxFeePerGas, + }, + parsedOrders.get(e.orderHash)?.order as DutchOrder | CosignedV2DutchOrder | CosignedV3DutchOrder | CosignedPriorityOrder + ) + orderUpdates.set(e.orderHash, { + status: ORDER_STATUS.FILLED, + txHash: fillEvent.txHash, + fillBlock: fillEvent.blockNumber, + settledAmounts: settledAmounts, + }) + } + else { + orderUpdates.set(e.orderHash, { + status: ORDER_STATUS.FILLED, + }) + } + } + })) + + break // Success - exit the retry loop + } catch (error) { + attempts++ + recentErrors++ + console.log(`Failed to get fill events for blocks ${i - BLOCK_RANGE} to ${i}, error: ${error}`) + log.error({ error }, `Failed to get fill events for blocks ${i - BLOCK_RANGE} to ${i}`) + if (attempts === CRON_MAX_ATTEMPTS) { + log.error({ error }, `Failed to get fill events after ${attempts} attempts for blocks ${i - BLOCK_RANGE} to ${i}`) + metrics?.putMetric(`GetFillEventsError`, 1, Unit.Count) + break // Skip this range and continue with the next one + } + // Wait time is determined by the number of recent errors + await new Promise(resolve => setTimeout(resolve, 1000 * recentErrors)) + } + } } - return [] - }) - log.info({ staleOrders }, `Found ${staleOrders.length} stale orders`) - if (staleOrders.length > 0) { - try { - await repo.deleteOrders(staleOrders) - } catch (e) { - metrics?.putMetric('DeleteStaleOrdersError', 1, Unit.Count) - log.error({ error: e }, 'Failed to delete stale orders') - throw e + } + + // Loop through unfilled orders and see if they were cancelled + const quoter = new OrderValidator(provider, chainId) + for (const orderHash of parsedOrders.keys()) { + if (!orderUpdates.has(orderHash)) { + const validation = await quoter.validate({ + order: parsedOrders.get(orderHash)!.order, + signature: parsedOrders.get(orderHash)!.signature, + }) + if (validation === OrderValidation.NonceUsed) { + orderUpdates.set(orderHash, { + status: ORDER_STATUS.CANCELLED, + }) + } + if (validation === OrderValidation.Expired) { + orderUpdates.set(orderHash, { + status: ORDER_STATUS.EXPIRED, + }) + } } } - if (openOrders.cursor) { - openOrders = await repo.getByOrderStatus(ORDER_STATUS.OPEN, DYNAMO_BATCH_WRITE_MAX, openOrders.cursor) - } else { - break + + // Update the orders in the database + log.info(`Updating ${orderUpdates.size} incorrect orders`) + for (const [orderHash, orderUpdate] of orderUpdates) { + await repo.updateOrderStatus( + orderHash, + orderUpdate.status, + orderUpdate.txHash, + orderUpdate.fillBlock, + orderUpdate.settledAmounts + ) + + metrics?.putMetric(`UpdateOrderStatus_${orderUpdate.status}`, 1, Unit.Count) } + log.info(`Update complete`) } } + +/** + * Get all open orders from the database and parse them + * @param repo - The orders repository + * @param chainId - The chain ID + * @returns A map of order hashes to their parsed order data + */ +async function getParsedOrders(repo: BaseOrdersRepository, chainId: ChainId) { + + // Collect all open orders + let cursor: string | undefined = undefined + let allOrders: UniswapXOrderEntity[] = [] + do { + const openOrders: QueryResult = await repo.getOrders(DYNAMO_BATCH_WRITE_MAX, { + orderStatus: ORDER_STATUS.OPEN, + chainId: chainId, + cursor: cursor, + }) + cursor = openOrders.cursor + allOrders = allOrders.concat(openOrders.orders) + + } while (cursor) + const parsedOrders = new Map() + allOrders.forEach((o) => parsedOrders.set(o.orderHash, {order: parseOrder(o, chainId), signature: o.signature, deadline: o.deadline})) + return parsedOrders +} \ No newline at end of file diff --git a/lib/handlers/OrderParser.ts b/lib/handlers/OrderParser.ts new file mode 100644 index 00000000..fd2cb063 --- /dev/null +++ b/lib/handlers/OrderParser.ts @@ -0,0 +1,19 @@ +import { CosignedPriorityOrder, CosignedV2DutchOrder, CosignedV3DutchOrder, DutchOrder, UniswapXOrder, OrderType } from '@uniswap/uniswapx-sdk' +import { ChainId } from '../util/chain' +import { UniswapXOrderEntity } from '../entities' + +export function parseOrder(order: UniswapXOrderEntity, chainId: ChainId): UniswapXOrder { + switch (order.type) { + case OrderType.Dutch: + case OrderType.Limit: + return DutchOrder.parse(order.encodedOrder, chainId) + case OrderType.Dutch_V2: + return CosignedV2DutchOrder.parse(order.encodedOrder, chainId) + case OrderType.Dutch_V3: + return CosignedV3DutchOrder.parse(order.encodedOrder, chainId) + case OrderType.Priority: + return CosignedPriorityOrder.parse(order.encodedOrder, chainId) + default: + throw new Error(`Unsupported OrderType ${JSON.stringify(order)}, No Parser Configured`) + } +} diff --git a/lib/handlers/check-order-status/service.ts b/lib/handlers/check-order-status/service.ts index d34d774b..c7881d64 100644 --- a/lib/handlers/check-order-status/service.ts +++ b/lib/handlers/check-order-status/service.ts @@ -8,7 +8,6 @@ import { OrderValidation, OrderValidator, UniswapXEventWatcher, - UniswapXOrder, } from '@uniswap/uniswapx-sdk' import { ethers } from 'ethers' import { ORDER_STATUS, RelayOrderEntity, SettledAmount, UniswapXOrderEntity } from '../../entities' @@ -22,6 +21,7 @@ import { metrics } from '../../util/metrics' import { SfnStateInputOutput } from '../base' import { FillEventLogger } from './fill-event-logger' import { getSettledAmounts, IS_TERMINAL_STATE } from './util' +import { parseOrder } from '../OrderParser' const FILL_CHECK_OVERLAP_BLOCK = 20 @@ -66,7 +66,6 @@ export class CheckOrderStatusService { orderQuoter, orderWatcher, orderStatus, - orderType, }: CheckOrderStatusRequest): Promise { const order: UniswapXOrderEntity = checkDefined( await wrapWithTimerMetric( @@ -76,25 +75,7 @@ export class CheckOrderStatusService { `cannot find order by hash when updating order status, hash: ${orderHash}` ) - let parsedOrder: UniswapXOrder - switch (orderType) { - case OrderType.Dutch: - case OrderType.Limit: - parsedOrder = DutchOrder.parse(order.encodedOrder, chainId) - break - case OrderType.Dutch_V2: - parsedOrder = CosignedV2DutchOrder.parse(order.encodedOrder, chainId) - break - case OrderType.Dutch_V3: - parsedOrder = CosignedV3DutchOrder.parse(order.encodedOrder, chainId) - break - case OrderType.Priority: - parsedOrder = CosignedPriorityOrder.parse(order.encodedOrder, chainId) - break - default: - throw new Error(`Unsupported OrderType ${orderType}, No Parser Configured`) - } - + const parsedOrder = parseOrder(order, chainId) const validation = await wrapWithTimerMetric( orderQuoter.validate({ order: parsedOrder, diff --git a/lib/util/constants.ts b/lib/util/constants.ts index 4069c87e..177400ee 100644 --- a/lib/util/constants.ts +++ b/lib/util/constants.ts @@ -1,3 +1,5 @@ +import { ChainId } from "./chain" + export const WEBHOOK_CONFIG_BUCKET = 'order-webhook-notification-config' export const PRODUCTION_WEBHOOK_CONFIG_KEY = 'production.json' export const BETA_WEBHOOK_CONFIG_KEY = 'beta.json' @@ -5,6 +7,19 @@ export const NATIVE_ADDRESS = '0x0000000000000000000000000000000000000000' export const ONE_HOUR_IN_SECONDS = 60 * 60 export const ONE_DAY_IN_SECONDS = 60 * 60 * 24 export const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365 - +export const OLDEST_BLOCK_BY_CHAIN = { + [ChainId.MAINNET]: 20120259, + [ChainId.ARBITRUM_ONE]: 253597707, + [ChainId.BASE]: 22335646, + [ChainId.UNICHAIN]: 6747397, +} +export const BLOCK_TIME_MS_BY_CHAIN = { + [ChainId.MAINNET]: 12000, + [ChainId.ARBITRUM_ONE]: 250, + [ChainId.BASE]: 2000, + [ChainId.UNICHAIN]: 1000, +} +export const BLOCK_RANGE = 10000 +export const CRON_MAX_ATTEMPTS = 10 //Dynamo limits batch write to 25 export const DYNAMO_BATCH_WRITE_MAX = 25 diff --git a/test/integ/crons/gs-reaper.test.ts b/test/integ/crons/gs-reaper.test.ts deleted file mode 100644 index b11ad9fd..00000000 --- a/test/integ/crons/gs-reaper.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -// @ts-nocheck -import { OrderType, REACTOR_ADDRESS_MAPPING } from '@uniswap/uniswapx-sdk' -import { DocumentClient } from 'aws-sdk/clients/dynamodb' -import { default as bunyan, default as Logger } from 'bunyan' -import { deleteStaleOrders } from '../../../lib/crons/gs-reaper' -import { ORDER_STATUS } from '../../../lib/entities' -import { DutchOrdersRepository } from '../../../lib/repositories/dutch-orders-repository' -import { DYNAMO_BATCH_WRITE_MAX } from '../../../lib/util/constants' -import { deleteAllRepoEntries } from '../repositories/deleteAllRepoEntries' - -const dynamoConfig = { - convertEmptyValues: true, - endpoint: 'localhost:8000', - region: 'local-env', - sslEnabled: false, - credentials: { - accessKeyId: 'fakeMyKeyId', - secretAccessKey: 'fakeSecretAccessKey', - }, -} -const documentClient = new DocumentClient(dynamoConfig) -const ordersRepository = DutchOrdersRepository.create(documentClient) - -const MOCK_ORDER = { - encodedOrder: '0x01', - chainId: 1, - filler: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - signature: - '0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010', - nonce: '40', - orderHash: '0x0', - orderStatus: ORDER_STATUS.OPEN, - offerer: '0x0000000000000000000000000000000000000001', - reactor: REACTOR_ADDRESS_MAPPING[1][OrderType.Dutch].toLowerCase(), - decayStartTime: 20, - decayEndTime: 10, - deadline: 10, - quoteId: '55e2cfca-5521-4a0a-b597-7bfb569032d7', - type: 'Dutch', - input: { - endAmount: '30', - startAmount: '30', - token: '0x0000000000000000000000000000000000000003', - }, - outputs: [ - { - endAmount: '50', - startAmount: '60', - token: '0x0000000000000000000000000000000000000005', - recipient: '0x0000000000000000000000000000000000000004', - }, - ], -} -const log: Logger = bunyan.createLogger({ - name: 'test', - serializers: bunyan.stdSerializers, - level: 'fatal', -}) - -describe('deleteStaleOrders Test', () => { - beforeEach(async () => { - const orders = await ordersRepository.getByOrderStatus(ORDER_STATUS.OPEN) - if (orders.orders.length) { - await ordersRepository.deleteOrders(orders.orders.map((order) => order.orderHash)) - } - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - afterAll(async () => { - await deleteAllRepoEntries(ordersRepository) - }) - - it('should delete stale orders', async () => { - await ordersRepository.putOrderAndUpdateNonceTransaction(MOCK_ORDER) - let staleOrders = await ordersRepository.getByOrderStatus(ORDER_STATUS.OPEN) - expect(staleOrders.orders.length).toBe(1) - await deleteStaleOrders(ordersRepository, log) - staleOrders = await ordersRepository.getByOrderStatus(ORDER_STATUS.OPEN) - expect(staleOrders.orders.length).toBe(0) - }) - - it('should page through all stale orders if necessary', async () => { - for (let i = 0; i < DYNAMO_BATCH_WRITE_MAX + 1; i++) { - await ordersRepository.putOrderAndUpdateNonceTransaction({ ...MOCK_ORDER, orderHash: `0x${i}` }) - } - await deleteStaleOrders(ordersRepository, log) - for (let i = 0; i < DYNAMO_BATCH_WRITE_MAX + 1; i++) { - expect(await ordersRepository.getByHash(`0x${i}`)).toBeUndefined() - } - }) -}) diff --git a/test/test-data.ts b/test/test-data.ts index d6c6f4e6..64744796 100644 --- a/test/test-data.ts +++ b/test/test-data.ts @@ -33,7 +33,7 @@ export const MOCK_ORDER_ENTITY: UniswapXOrderEntity = { }, ], } - +export const MOCK_V2_ORDER_HASH = '0x2222222222222222222222222222222222222222222222222222222222222222' const MOCK_V2_ENCODED_ORDER = '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000100000000000000000000000000d93328ab0a4d59bb30eb881e29df68ba69596280000000000000000000000000cf7ed3acca5a467e9e704c703e8d87f634fb0fc90000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000028000000000000000000000000000000000000000000000000000000000000003800000000000000000000000003867393cc6ea7b0414c2c3e1d9fe7cea987fd0660000000000000000000000005d5577a33abbbae5e7235d2d080caff47e94b7d000000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000065fc92ef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000dc64a140aa3e981100a9beca4e685f962f0cf6c90000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000005d5577a33abbbae5e7235d2d080caff47e94b7d00000000000000000000000000000000000000000000000000000000065fc92e50000000000000000000000000000000000000000000000000000000065fc92ef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000000000000000000414751f7f9cd42d3b1164487e012b348039b3eb538938837aceb07052f74ca1dd1627412bf1096c8df4d4a02ed484d7b3ca3db505c84ad9d61c2a96b93d7301d7b1c00000000000000000000000000000000000000000000000000000000000000' const MOCK_V2_SIGNATURE = @@ -42,10 +42,10 @@ export const MOCK_V2_ORDER_ENTITY: UniswapXOrderEntity = { encodedOrder: MOCK_V2_ENCODED_ORDER, signature: MOCK_V2_SIGNATURE, nonce: '0xnonce', - orderHash: MOCK_ORDER_HASH, + orderHash: MOCK_V2_ORDER_HASH, offerer: '0xofferer', orderStatus: ORDER_STATUS.OPEN, - type: OrderType.Dutch, + type: OrderType.Dutch_V2, chainId: 1, reactor: REACTOR_ADDRESS_MAPPING[1][OrderType.Dutch_V2] as string, decayStartTime: 1, @@ -64,6 +64,14 @@ export const MOCK_V2_ORDER_ENTITY: UniswapXOrderEntity = { recipient: '0xrecipient', }, ], + cosignerData: { + decayStartTime: 1, + decayEndTime: 2, + exclusiveFiller: '0x0000000000000000000000000000000000000000', + inputOverride: '0', + outputOverrides: ['0'] + }, + cosignature: '0x0', } export const dynamoConfig = { diff --git a/test/unit/handlers/gs-reaper/gs-reaper.test.ts b/test/unit/handlers/gs-reaper/gs-reaper.test.ts new file mode 100644 index 00000000..3f002399 --- /dev/null +++ b/test/unit/handlers/gs-reaper/gs-reaper.test.ts @@ -0,0 +1,288 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +import { OrderType, REACTOR_ADDRESS_MAPPING, OrderValidation } from '@uniswap/uniswapx-sdk' +import { default as bunyan, default as Logger } from 'bunyan' +import { deleteStaleOrders } from '../../../../lib/crons/gs-reaper' +import { ORDER_STATUS } from '../../../../lib/entities' +import { DutchOrdersRepository } from '../../../../lib/repositories/dutch-orders-repository' +import { BLOCK_RANGE, OLDEST_BLOCK_BY_CHAIN } from '../../../../lib/util/constants' +import { ChainId } from '../../../../lib/util/chain' +import { cleanupOrphanedOrders } from '../../../../lib/crons/gs-reaper' +import { MOCK_ORDER_ENTITY, MOCK_V2_ORDER_ENTITY } from '../../../test-data' + +const dynamoConfig = { + convertEmptyValues: true, + endpoint: 'localhost:8000', + region: 'local-env', + sslEnabled: false, + credentials: { + accessKeyId: 'fakeMyKeyId', + secretAccessKey: 'fakeSecretAccessKey', + }, +} + +const log: Logger = bunyan.createLogger({ + name: 'test', + serializers: bunyan.stdSerializers, + level: 'fatal', +}) + +const mockOrdersRepository = { + orders: new Map(), + + addOrder: jest.fn(async (order) => { + mockOrdersRepository.orders.set(order.orderHash, { ...order }) + }), + + getOrder: jest.fn(async (orderHash) => { + return mockOrdersRepository.orders.get(orderHash) || null + }), + + getOrders: jest.fn(async (limit, { orderStatus, chainId, cursor }) => { + const matchingOrders = Array.from(mockOrdersRepository.orders.values()) + .filter(order => + order.orderStatus === orderStatus && + order.chainId === chainId + ) + .slice(0, limit) + + return { + orders: matchingOrders, + cursor: undefined // Simplified cursor implementation for testing + } + }), + + updateOrderStatus: jest.fn(async (orderHash, status, txHash, fillBlock, settledAmounts) => { + const order = mockOrdersRepository.orders.get(orderHash) + if (order) { + mockOrdersRepository.orders.set(orderHash, { + ...order, + orderStatus: status, + txHash, + fillBlock, + settledAmounts + }) + } + }) +} + +// Setup mock provider +const mockProviders = new Map() +for (const chainIdKey of Object.keys(OLDEST_BLOCK_BY_CHAIN)) { + const chainId = Number(chainIdKey) + const mockProvider = { + getBlockNumber: jest.fn().mockResolvedValue(OLDEST_BLOCK_BY_CHAIN[chainId] + BLOCK_RANGE), + getTransaction: jest.fn().mockResolvedValue({ + gasPrice: '1000000000', + maxPriorityFeePerGas: null, + maxFeePerGas: null, + }), + getBlock: jest.fn().mockResolvedValue({ + timestamp: Date.now() / 1000, + }), + } + mockProviders.set(chainId, mockProvider); +} + +// Setup mock watcher +const mockFillBlockNumber = OLDEST_BLOCK_BY_CHAIN[ChainId.MAINNET] + BLOCK_RANGE/2 +const mockWatcher = { + getFillEvents: jest.fn().mockImplementation(async (chainId, fromBlock, toBlock) => { + // Only return events if the block range matches expected range + if (mockFillBlockNumber >= fromBlock && mockFillBlockNumber <= toBlock) { + return [ + { orderHash: MOCK_ORDER_ENTITY.orderHash }, + { orderHash: MOCK_V2_ORDER_ENTITY.orderHash }, + ] + } + return [] + }), + getFillInfo: jest.fn().mockResolvedValue([{ + orderHash: MOCK_ORDER_ENTITY.orderHash, + txHash: '0xmocktxhash', + blockNumber: mockFillBlockNumber, + }, + { + orderHash: MOCK_V2_ORDER_ENTITY.orderHash, + txHash: '0xmocktxhash2', + blockNumber: mockFillBlockNumber, + }]), +} + +// Mock the UniswapXEventWatcher constructor +jest.mock('@uniswap/uniswapx-sdk', () => { + const actual = jest.requireActual('@uniswap/uniswapx-sdk'); + return { + ...actual, + UniswapXEventWatcher: jest.fn().mockImplementation(() => mockWatcher), + OrderValidator: jest.fn().mockImplementation(() => ({ + validate: jest.fn().mockResolvedValue(actual.OrderValidation.OK) + })), + OrderValidation: actual.OrderValidation // Ensure we're using the actual enum + } +}) + +// Mock the getSettledAmounts function +jest.mock('../../../../lib/handlers/check-order-status/util', () => { + // Get reference to actual test-data import + const testData = jest.requireActual('../../../test-data') + + return { + getSettledAmounts: jest.fn().mockReturnValue([ + { + tokenOut: testData.MOCK_ORDER_ENTITY.outputs[0].token, + amountOut: testData.MOCK_ORDER_ENTITY.outputs[0].startAmount, + tokenIn: testData.MOCK_ORDER_ENTITY.input.token, + amountIn: testData.MOCK_ORDER_ENTITY.input.startAmount, + } + ]) + } +}) + +describe('cleanupOrphanedOrders', () => { + beforeEach(async () => { + // Add test order to repository + await mockOrdersRepository.addOrder(MOCK_ORDER_ENTITY) + mockWatcher.getFillEvents.mockResolvedValue([{ orderHash: MOCK_V2_ORDER_ENTITY.orderHash }, { orderHash: MOCK_ORDER_ENTITY.orderHash }]) + }) + + afterEach(async () => { + mockOrdersRepository.orders.clear() + jest.clearAllMocks() + }) + + it('updates order status to FILLED when matching fill event is found', async () => { + await cleanupOrphanedOrders(mockOrdersRepository, mockProviders, log) + + // Verify order was updated + const updatedOrder = await mockOrdersRepository.getOrder(MOCK_ORDER_ENTITY.orderHash) + expect(updatedOrder?.orderStatus).toBe(ORDER_STATUS.FILLED) + expect(updatedOrder?.txHash).toBe('0xmocktxhash') + expect(updatedOrder?.fillBlock).toBe(mockFillBlockNumber) + expect(updatedOrder?.settledAmounts).toBeDefined() + expect(updatedOrder?.settledAmounts[0].tokenOut).toBe(MOCK_ORDER_ENTITY.outputs[0].token) + expect(updatedOrder?.settledAmounts[0].amountOut).toBe(MOCK_ORDER_ENTITY.outputs[0].startAmount) + expect(updatedOrder?.settledAmounts[0].tokenIn).toBe(MOCK_ORDER_ENTITY.input.token) + expect(updatedOrder?.settledAmounts[0].amountIn).toBe(MOCK_ORDER_ENTITY.input.startAmount) + }) + + it('updates order status to CANCELLED when nonce is used', async () => { + // Remove fill event from mock watcher + mockWatcher.getFillEvents.mockResolvedValue([]) + + // Update the OrderValidator mock implementation for this test + const { OrderValidation } = jest.requireActual('@uniswap/uniswapx-sdk') + const mockOrderValidator = jest.requireMock('@uniswap/uniswapx-sdk').OrderValidator + mockOrderValidator.mockImplementation(() => ({ + validate: jest.fn().mockResolvedValue(OrderValidation.NonceUsed) + })) + + await cleanupOrphanedOrders(mockOrdersRepository, mockProviders, log) + + // Verify order was updated + const updatedOrder = await mockOrdersRepository.getOrder(MOCK_ORDER_ENTITY.orderHash) + expect(updatedOrder?.orderStatus).toBe(ORDER_STATUS.CANCELLED) + expect(updatedOrder?.txHash).not.toBeDefined() + expect(updatedOrder?.fillBlock).not.toBeDefined() + expect(updatedOrder?.settledAmounts).not.toBeDefined() + }) + + it('updates order status to EXPIRED when deadline has passed', async () => { + // Remove fill event from mock watcher + mockWatcher.getFillEvents.mockResolvedValue([]) + + // Update the OrderValidator mock implementation for this test + const { OrderValidation } = jest.requireActual('@uniswap/uniswapx-sdk') + const mockOrderValidator = jest.requireMock('@uniswap/uniswapx-sdk').OrderValidator + mockOrderValidator.mockImplementation(() => ({ + validate: jest.fn().mockResolvedValue(OrderValidation.Expired) + })) + + await cleanupOrphanedOrders(mockOrdersRepository, mockProviders, log) + + // Verify order was updated + const updatedOrder = await mockOrdersRepository.getOrder(MOCK_ORDER_ENTITY.orderHash) + expect(updatedOrder?.orderStatus).toBe(ORDER_STATUS.EXPIRED) + expect(updatedOrder?.txHash).not.toBeDefined() + expect(updatedOrder?.fillBlock).not.toBeDefined() + expect(updatedOrder?.settledAmounts).not.toBeDefined() + }) + + it('updates order status remains OPEN when not filled, cancelled, or expired', async () => { + mockOrdersRepository.orders.clear() + const unexpiredOrder = { ...MOCK_ORDER_ENTITY, deadline: Date.now() / 1000 + 10000 } + await mockOrdersRepository.addOrder(unexpiredOrder) + // Remove fill event from mock watcher + mockWatcher.getFillEvents.mockResolvedValue([]) + + // Update the OrderValidator mock implementation for this test + const { OrderValidation } = jest.requireActual('@uniswap/uniswapx-sdk') + const mockOrderValidator = jest.requireMock('@uniswap/uniswapx-sdk').OrderValidator + mockOrderValidator.mockImplementation(() => ({ + validate: jest.fn().mockResolvedValue(OrderValidation.OK) + })) + + await cleanupOrphanedOrders(mockOrdersRepository, mockProviders, log) + + // Verify order remains OPEN + const updatedOrder = await mockOrdersRepository.getOrder(unexpiredOrder.orderHash) + expect(updatedOrder?.orderStatus).toBe(ORDER_STATUS.OPEN) + expect(updatedOrder?.txHash).not.toBeDefined() + expect(updatedOrder?.fillBlock).not.toBeDefined() + expect(updatedOrder?.settledAmounts).not.toBeDefined() + }) + + it('updates multiple order types on a single chain', async () => { + await mockOrdersRepository.addOrder(MOCK_V2_ORDER_ENTITY) + await cleanupOrphanedOrders(mockOrdersRepository, mockProviders, log) + + // Verify order was updated + const updatedOrder = await mockOrdersRepository.getOrder(MOCK_ORDER_ENTITY.orderHash) + expect(updatedOrder?.orderStatus).toBe(ORDER_STATUS.FILLED) + expect(updatedOrder?.txHash).toBe('0xmocktxhash') + expect(updatedOrder?.fillBlock).toBe(mockFillBlockNumber) + expect(updatedOrder?.settledAmounts).toBeDefined() + expect(updatedOrder?.settledAmounts[0].tokenOut).toBe(MOCK_ORDER_ENTITY.outputs[0].token) + expect(updatedOrder?.settledAmounts[0].amountOut).toBe(MOCK_ORDER_ENTITY.outputs[0].startAmount) + expect(updatedOrder?.settledAmounts[0].tokenIn).toBe(MOCK_ORDER_ENTITY.input.token) + expect(updatedOrder?.settledAmounts[0].amountIn).toBe(MOCK_ORDER_ENTITY.input.startAmount) + + const updatedV2Order = await mockOrdersRepository.getOrder(MOCK_V2_ORDER_ENTITY.orderHash) + expect(updatedV2Order?.orderStatus).toBe(ORDER_STATUS.FILLED) + expect(updatedV2Order?.txHash).toBe('0xmocktxhash2') + expect(updatedV2Order?.fillBlock).toBe(mockFillBlockNumber) + expect(updatedV2Order?.settledAmounts).toBeDefined() + expect(updatedV2Order?.settledAmounts[0].tokenOut).toBe(MOCK_V2_ORDER_ENTITY.outputs[0].token) + expect(updatedV2Order?.settledAmounts[0].amountOut).toBe(MOCK_V2_ORDER_ENTITY.outputs[0].startAmount) + expect(updatedV2Order?.settledAmounts[0].tokenIn).toBe(MOCK_V2_ORDER_ENTITY.input.token) + expect(updatedV2Order?.settledAmounts[0].amountIn).toBe(MOCK_V2_ORDER_ENTITY.input.startAmount) + }) + + it('iterates through multiple blocks batches', async () => { + // Start 10 blocks ahead of the oldest block + const mockProvider = { + getBlockNumber: jest.fn().mockResolvedValue(OLDEST_BLOCK_BY_CHAIN[ChainId.MAINNET] + BLOCK_RANGE * 10), + getTransaction: jest.fn().mockResolvedValue({ + gasPrice: '1000000000', + maxPriorityFeePerGas: null, + maxFeePerGas: null, + }), + getBlock: jest.fn().mockResolvedValue({ + timestamp: Date.now() / 1000, + }), + } + mockProviders.set(ChainId.MAINNET, mockProvider) + await cleanupOrphanedOrders(mockOrdersRepository, mockProviders, log) + + // Verify order was updated + const updatedOrder = await mockOrdersRepository.getOrder(MOCK_ORDER_ENTITY.orderHash) + expect(updatedOrder?.orderStatus).toBe(ORDER_STATUS.FILLED) + expect(updatedOrder?.txHash).toBe('0xmocktxhash') + expect(updatedOrder?.fillBlock).toBe(mockFillBlockNumber) + expect(updatedOrder?.settledAmounts).toBeDefined() + expect(updatedOrder?.settledAmounts[0].tokenOut).toBe(MOCK_ORDER_ENTITY.outputs[0].token) + expect(updatedOrder?.settledAmounts[0].amountOut).toBe(MOCK_ORDER_ENTITY.outputs[0].startAmount) + expect(updatedOrder?.settledAmounts[0].tokenIn).toBe(MOCK_ORDER_ENTITY.input.token) + expect(updatedOrder?.settledAmounts[0].amountIn).toBe(MOCK_ORDER_ENTITY.input.startAmount) + }) +})