From fcac90dd51397800612d0b20a938509d7e6e7b18 Mon Sep 17 00:00:00 2001 From: Surbhit Agrawal <82264758+surbhit14@users.noreply.github.com> Date: Fri, 25 Oct 2024 14:08:49 +0530 Subject: [PATCH] 235 feat operator event history (#263) * Add route * Add Schema and Types * Add core logic for event fetching * Remove the unnecesary code * Add default range and max range * Capitalise the enum types * Add the case insensitive queries * Add optional fields underlyingToken and underlyingValue to response with flag * Move additional fields out of args and lowercase the response fields --- .../routes/operators/operatorController.ts | 222 ++++++++++++++++++ .../src/routes/operators/operatorRoutes.ts | 3 + .../src/schema/zod/schemas/operatorEvents.ts | 162 +++++++++++++ .../schema/zod/schemas/withTokenDataQuery.ts | 12 + 4 files changed, 399 insertions(+) create mode 100644 packages/api/src/schema/zod/schemas/operatorEvents.ts create mode 100644 packages/api/src/schema/zod/schemas/withTokenDataQuery.ts diff --git a/packages/api/src/routes/operators/operatorController.ts b/packages/api/src/routes/operators/operatorController.ts index 5d76443f..8180f1fc 100644 --- a/packages/api/src/routes/operators/operatorController.ts +++ b/packages/api/src/routes/operators/operatorController.ts @@ -6,6 +6,7 @@ import { WithAdditionalDataQuerySchema } from '../../schema/zod/schemas/withAddi import { SortByQuerySchema } from '../../schema/zod/schemas/sortByQuery' import { SearchByTextQuerySchema } from '../../schema/zod/schemas/searchByTextQuery' import { WithRewardsQuerySchema } from '../../schema/zod/schemas/withRewardsQuery' +import { OperatorEventQuerySchema } from '../../schema/zod/schemas/operatorEvents' import { handleAndReturnErrorResponse } from '../../schema/errors' import { getStrategiesWithShareUnderlying, @@ -16,6 +17,24 @@ import { withOperatorShares } from '../../utils/operatorShares' import Prisma from '@prisma/client' import prisma from '../../utils/prismaClient' import { fetchTokenPrices } from '../../utils/tokenPrices' +import { WithTokenDataQuerySchema } from '../../schema/zod/schemas/withTokenDataQuery' +import { getSharesToUnderlying } from '../../../../seeder/src/utils/strategies' + +type EventRecordArgs = { + staker: string + strategy?: string + shares?: number +} + +type EventRecord = { + type: 'SHARES_INCREASED' | 'SHARES_DECREASED' | 'DELEGATION' | 'UNDELEGATION' + tx: string + blockNumber: number + blockTime: Date + args: EventRecordArgs + underlyingToken?: string + underlyingValue?: number +} /** * Function for route /operators @@ -370,6 +389,106 @@ export async function getOperatorRewards(req: Request, res: Response) { } } +/** + * Function for route /operators/:address/events + * Fetches and returns a list of events for a specific operator with optional filters + * + * @param req + * @param res + */ +export async function getOperatorEvents(req: Request, res: Response) { + const result = OperatorEventQuerySchema.and(WithTokenDataQuerySchema) + .and(PaginationQuerySchema) + .safeParse(req.query) + if (!result.success) { + return handleAndReturnErrorResponse(req, res, result.error) + } + + try { + const { + type, + stakerAddress, + strategyAddress, + txHash, + startAt, + endAt, + withTokenData, + skip, + take + } = result.data + const { address } = req.params + + const baseFilterQuery = { + operator: { + contains: address, + mode: 'insensitive' + }, + ...(stakerAddress && { + staker: { + contains: stakerAddress, + mode: 'insensitive' + } + }), + ...(strategyAddress && { + strategy: { + contains: strategyAddress, + mode: 'insensitive' + } + }), + ...(txHash && { + transactionHash: { + contains: txHash, + mode: 'insensitive' + } + }), + blockTime: { + gte: new Date(startAt), + ...(endAt ? { lte: new Date(endAt) } : {}) + } + } + + let eventRecords: EventRecord[] = [] + let eventCount = 0 + + const eventTypesToFetch = type + ? [type] + : strategyAddress + ? ['SHARES_INCREASED', 'SHARES_DECREASED'] + : ['SHARES_INCREASED', 'SHARES_DECREASED', 'DELEGATION', 'UNDELEGATION'] + + const fetchEventsForTypes = async (types: string[]) => { + const results = await Promise.all( + types.map((eventType) => + fetchAndMapEvents(eventType, baseFilterQuery, withTokenData, skip, take) + ) + ) + return results + } + + const results = await fetchEventsForTypes(eventTypesToFetch) + + eventRecords = results.flatMap((result) => result.eventRecords) + eventRecords = eventRecords + .sort((a, b) => (b.blockNumber > a.blockNumber ? 1 : -1)) + .slice(0, take) + + eventCount = results.reduce((acc, result) => acc + result.eventCount, 0) + + const response = { + data: eventRecords, + meta: { + total: eventCount, + skip, + take + } + } + + res.send(response) + } catch (error) { + handleAndReturnErrorResponse(req, res, error) + } +} + /** * Function for route /operators/:address/invalidate-metadata * Protected route to invalidate the metadata of a given Operator @@ -555,3 +674,106 @@ async function calculateOperatorApy(operator: any) { return response } catch {} } + +/** + * Utility function to fetch and map event records from the database. + * + * @param eventType + * @param baseFilterQuery + * @param skip + * @param take + * @returns + */ +async function fetchAndMapEvents( + eventType: string, + baseFilterQuery: any, + withTokenData: boolean, + skip: number, + take: number +): Promise<{ eventRecords: EventRecord[]; eventCount: number }> { + const modelName = (() => { + switch (eventType) { + case 'SHARES_INCREASED': + return 'eventLogs_OperatorSharesIncreased' + case 'SHARES_DECREASED': + return 'eventLogs_OperatorSharesDecreased' + case 'DELEGATION': + return 'eventLogs_StakerDelegated' + case 'UNDELEGATION': + return 'eventLogs_StakerUndelegated' + default: + throw new Error(`Unknown event type: ${eventType}`) + } + })() + + const model = prisma[modelName] as any + + const eventCount = await model.count({ + where: baseFilterQuery + }) + + const eventLogs = await model.findMany({ + where: baseFilterQuery, + skip, + take, + orderBy: { blockNumber: 'desc' } + }) + + const tokenPrices = withTokenData ? await fetchTokenPrices() : undefined + const sharesToUnderlying = withTokenData ? await getSharesToUnderlying() : undefined + + const eventRecords = await Promise.all( + eventLogs.map(async (event) => { + let underlyingToken: string | undefined + let underlyingValue: number | undefined + + if ( + withTokenData && + (eventType === 'SHARES_INCREASED' || eventType === 'SHARES_DECREASED') && + event.strategy + ) { + const strategy = await prisma.strategies.findUnique({ + where: { + address: event.strategy.toLowerCase() + } + }) + + if (strategy && sharesToUnderlying) { + underlyingToken = strategy.underlyingToken + const sharesMultiplier = Number(sharesToUnderlying.get(event.strategy.toLowerCase())) + const strategyTokenPrice = tokenPrices?.find( + (tp) => tp.address.toLowerCase() === strategy.underlyingToken.toLowerCase() + ) + + if (sharesMultiplier && strategyTokenPrice) { + underlyingValue = + (Number(event.shares) / Math.pow(10, strategyTokenPrice.decimals)) * + sharesMultiplier * + strategyTokenPrice.ethPrice + } + } + } + + return { + type: eventType, + tx: event.transactionHash, + blockNumber: event.blockNumber, + blockTime: event.blockTime, + args: { + staker: event.staker.toLowerCase(), + strategy: event.strategy?.toLowerCase(), + shares: event.shares + }, + ...(withTokenData && { + underlyingToken: underlyingToken?.toLowerCase(), + underlyingValue + }) + } + }) + ) + + return { + eventRecords, + eventCount + } +} diff --git a/packages/api/src/routes/operators/operatorRoutes.ts b/packages/api/src/routes/operators/operatorRoutes.ts index 13ae1643..218a781e 100644 --- a/packages/api/src/routes/operators/operatorRoutes.ts +++ b/packages/api/src/routes/operators/operatorRoutes.ts @@ -4,6 +4,7 @@ import { getOperator, getAllOperatorAddresses, getOperatorRewards, + getOperatorEvents, invalidateMetadata } from './operatorController' import { authenticateJWT } from '../../utils/jwtUtils' @@ -108,6 +109,8 @@ router.get('/:address', routeCache.cacheSeconds(120), getOperator) router.get('/:address/rewards', routeCache.cacheSeconds(120), getOperatorRewards) +router.get('/:address/events', routeCache.cacheSeconds(120), getOperatorEvents) + // Protected routes router.get( '/:address/invalidate-metadata', diff --git a/packages/api/src/schema/zod/schemas/operatorEvents.ts b/packages/api/src/schema/zod/schemas/operatorEvents.ts new file mode 100644 index 00000000..57838b90 --- /dev/null +++ b/packages/api/src/schema/zod/schemas/operatorEvents.ts @@ -0,0 +1,162 @@ +import z from '../' + +const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ +const yyyymmddRegex = /^\d{4}-\d{2}-\d{2}$/ +const maxDuration = 30 * 24 * 60 * 60 * 1000 // 30 days +const defaultDuration = 7 * 24 * 60 * 60 * 1000 // 7 days + +/** + * Validates that the given time range doesn't exceed the max allowed duration. + * + * @param startAt + * @param endAt + * @returns + */ +const validateDateRange = (startAt: string, endAt: string) => { + const start = new Date(startAt) + const end = new Date(endAt || new Date()) + const durationMs = end.getTime() - start.getTime() + return durationMs <= maxDuration +} + +/** + * Utility to get default dates if not provided. + * Default to last 7 days + * + * @param startAt + * @param endAt + * @returns + */ +const getValidatedDates = (startAt?: string, endAt?: string) => { + const now = new Date() + + if (!startAt && !endAt) { + return { + startAt: new Date(now.getTime() - defaultDuration).toISOString(), + endAt: null + } + } + + if (startAt && !endAt) { + const start = new Date(startAt) + return { + startAt, + endAt: new Date(Math.min(start.getTime() + defaultDuration, now.getTime())).toISOString() + } + } + + if (!startAt && endAt) { + const end = new Date(endAt) + return { + startAt: new Date(end.getTime() - defaultDuration).toISOString(), + endAt + } + } + + return { startAt, endAt } +} + +export const OperatorEventQuerySchema = z + .object({ + stakerAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address') + .optional() + .describe('The address of the staker') + .openapi({ example: '0x74ede5f75247fbdb9266d2b3a7be63b3db7611dd' }), + + strategyAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address') + .optional() + .describe('The contract address of the restaking strategy') + .openapi({ example: '0x0fe4f44bee93503346a3ac9ee5a26b130a5796d6' }), + + txHash: z + .string() + .regex(/^0x([A-Fa-f0-9]{64})$/, 'Invalid transaction hash') + .optional() + .describe('The transaction hash associated with the event') + .openapi({ example: '0xe95a203b1a91a908f9b9ce46459d101078c2c3cb' }), + + type: z + .enum(['SHARES_INCREASED', 'SHARES_DECREASED', 'DELEGATION', 'UNDELEGATION']) + .optional() + .describe('The type of the operator event') + .openapi({ example: 'SHARES_INCREASED' }), + + startAt: z + .string() + .optional() + .refine( + (val) => + !val || + ((isoRegex.test(val) || yyyymmddRegex.test(val)) && + !Number.isNaN(new Date(val).getTime())), + { + message: 'Invalid date format for startAt. Use YYYY-MM-DD or ISO 8601 format.' + } + ) + .default('') + .describe('Start date in ISO string format') + .openapi({ example: '2024-04-11T08:31:11.000' }), + + endAt: z + .string() + .optional() + .refine( + (val) => + !val || + ((isoRegex.test(val) || yyyymmddRegex.test(val)) && + !Number.isNaN(new Date(val).getTime())), + { + message: 'Invalid date format for endAt. Use YYYY-MM-DD or ISO 8601 format.' + } + ) + .default('') + .describe('End date in ISO string format') + .openapi({ example: '2024-04-12T08:31:11.000' }) + }) + .refine( + (data) => { + if ((data.type === 'DELEGATION' || data.type === 'UNDELEGATION') && data.strategyAddress) { + return false + } + return true + }, + { + message: + 'strategyAddress filter is not supported for DELEGATION or UNDELEGATION event types.', + path: ['strategyAddress'] + } + ) + .refine( + (data) => { + if (data.startAt && data.endAt) { + return new Date(data.endAt).getTime() >= new Date(data.startAt).getTime() + } + return true + }, + { + message: 'endAt must be after startAt', + path: ['endAt'] + } + ) + .refine( + (data) => { + try { + const dates = getValidatedDates(data.startAt, data.endAt) + Object.assign(data, dates) + + return validateDateRange(data.startAt, data.endAt) + } catch { + return false + } + }, + { + message: 'Duration between startAt and endAt exceeds the allowed limit of 30 days.', + path: ['startAt', 'endAt'] + } + ) + +export default OperatorEventQuerySchema diff --git a/packages/api/src/schema/zod/schemas/withTokenDataQuery.ts b/packages/api/src/schema/zod/schemas/withTokenDataQuery.ts new file mode 100644 index 00000000..520a6eee --- /dev/null +++ b/packages/api/src/schema/zod/schemas/withTokenDataQuery.ts @@ -0,0 +1,12 @@ +import z from '..' + +export const WithTokenDataQuerySchema = z.object({ + withTokenData: z + .enum(['true', 'false']) + .default('false') + .describe( + 'Toggle whether the route should return underlying token address and underlying value' + ) + .transform((val) => val === 'true') + .openapi({ example: 'false' }) +})