From a5855000125bbeb2c81ae4854e7a5860f457bd22 Mon Sep 17 00:00:00 2001 From: prathamesh0 <42446521+prathamesh0@users.noreply.github.com> Date: Wed, 18 Sep 2024 15:37:13 +0530 Subject: [PATCH] Support topics filtering in getLogs ETH RPC API (#537) * Store event topics in separate columns in db * Store event data in a separate column in db * Support topics filter in eth_getLogs RPC API * Make RPC server path configurable * Sort logs result by log index --- packages/codegen/src/data/entities/Event.yaml | 38 +++++++ .../src/templates/config-template.handlebars | 16 +-- packages/util/src/config.ts | 19 +++- packages/util/src/eth-rpc-handlers.ts | 107 +++++++++++++++--- packages/util/src/indexer.ts | 9 +- packages/util/src/server.ts | 12 +- packages/util/src/types.ts | 5 + 7 files changed, 171 insertions(+), 35 deletions(-) diff --git a/packages/codegen/src/data/entities/Event.yaml b/packages/codegen/src/data/entities/Event.yaml index 1f937a2c4..780b2657d 100644 --- a/packages/codegen/src/data/entities/Event.yaml +++ b/packages/codegen/src/data/entities/Event.yaml @@ -44,6 +44,44 @@ columns: columnOptions: - option: length value: 256 + - name: topic0 + pgType: varchar + tsType: string + columnType: Column + columnOptions: + - option: length + value: 66 + - name: topic1 + pgType: varchar + tsType: string | null + columnType: Column + columnOptions: + - option: length + value: 66 + - option: nullable + value: true + - name: topic2 + pgType: varchar + tsType: string | null + columnType: Column + columnOptions: + - option: length + value: 66 + - option: nullable + value: true + - name: topic3 + pgType: varchar + tsType: string | null + columnType: Column + columnOptions: + - option: length + value: 66 + - option: nullable + value: true + - name: data + pgType: varchar + tsType: string + columnType: Column - name: eventInfo pgType: text tsType: string diff --git a/packages/codegen/src/templates/config-template.handlebars b/packages/codegen/src/templates/config-template.handlebars index 37ca714e9..22973d336 100644 --- a/packages/codegen/src/templates/config-template.handlebars +++ b/packages/codegen/src/templates/config-template.handlebars @@ -25,13 +25,7 @@ # Flag to specify whether RPC endpoint supports block hash as block tag parameter rpcSupportsBlockHashParam = true - # Enable ETH JSON RPC server at /rpc - enableEthRPCServer = true - - # Max number of logs that can be returned in a single getLogs request (default: 10000) - ethGetLogsResultLimit = 10000 - - # Server GQL config + # GQL server config [server.gql] path = "/graphql" @@ -55,6 +49,14 @@ timeTravelMaxAge = 86400 # 1 day {{/if}} + # ETH RPC server config + [server.ethRPC] + enabled = true + path = "/rpc" + + # Max number of logs that can be returned in a single getLogs request (default: 10000) + getLogsResultLimit = 10000 + [metrics] host = "127.0.0.1" port = 9000 diff --git a/packages/util/src/config.ts b/packages/util/src/config.ts index 62b3b096e..a7daedd8e 100644 --- a/packages/util/src/config.ts +++ b/packages/util/src/config.ts @@ -227,6 +227,18 @@ export interface GQLConfig { logDir?: string; } +// ETH RPC server config +export interface EthRPCConfig { + // Enable ETH JSON RPC server + enabled: boolean; + + // Path to expose the RPC server at + path?: string; + + // Max number of logs that can be returned in a single getLogs request + getLogsResultLimit?: number; +} + export interface ServerConfig { host: string; port: number; @@ -253,11 +265,8 @@ export interface ServerConfig { // https://ethereum.org/en/developers/docs/apis/json-rpc/#default-block rpcSupportsBlockHashParam: boolean; - // Enable ETH JSON RPC server at /rpc - enableEthRPCServer: boolean; - - // Max number of logs that can be returned in a single getLogs request - ethGetLogsResultLimit?: number; + // ETH JSON RPC server config + ethRPC: EthRPCConfig; } export interface FundingAmountsConfig { diff --git a/packages/util/src/eth-rpc-handlers.ts b/packages/util/src/eth-rpc-handlers.ts index 7cfbb16ae..a66552b3c 100644 --- a/packages/util/src/eth-rpc-handlers.ts +++ b/packages/util/src/eth-rpc-handlers.ts @@ -18,8 +18,9 @@ const ERROR_CONTRACT_METHOD_NOT_FOUND = 'Contract method not found'; const ERROR_METHOD_NOT_IMPLEMENTED = 'Method not implemented'; const ERROR_INVALID_BLOCK_TAG = 'Invalid block tag'; const ERROR_INVALID_BLOCK_HASH = 'Invalid block hash'; +const ERROR_INVALID_CONTRACT_ADDRESS = 'Invalid contract address'; +const ERROR_INVALID_TOPICS = 'Invalid topics'; const ERROR_BLOCK_NOT_FOUND = 'Block not found'; -const ERROR_TOPICS_FILTER_NOT_SUPPORTED = 'Topics filter not supported'; const ERROR_LIMIT_EXCEEDED = 'Query results exceeds limit'; const DEFAULT_BLOCK_TAG = 'latest'; @@ -114,20 +115,14 @@ export const createEthRPCHandlers = async ( // Parse arg params into where options const where: FindConditions = {}; - // TODO: Support topics filter - if (params.topics) { - throw new ErrorWithCode(CODE_INVALID_PARAMS, ERROR_TOPICS_FILTER_NOT_SUPPORTED); - } - // Address filter, address or a list of addresses if (params.address) { - if (Array.isArray(params.address)) { - if (params.address.length > 0) { - where.contract = In(params.address); - } - } else { - where.contract = Equal(params.address); - } + buildAddressFilter(params.address, where); + } + + // Topics filter + if (params.topics) { + buildTopicsFilter(params.topics, where); } // Block hash takes precedence over fromBlock / toBlock if provided @@ -158,8 +153,14 @@ export const createEthRPCHandlers = async ( // Fetch events from the db // Load block relation - const resultLimit = indexer.serverConfig.ethGetLogsResultLimit || DEFAULT_ETH_GET_LOGS_RESULT_LIMIT; - const events = await indexer.getEvents({ where, relations: ['block'], take: resultLimit + 1 }); + const resultLimit = indexer.serverConfig.ethRPC.getLogsResultLimit || DEFAULT_ETH_GET_LOGS_RESULT_LIMIT; + const events = await indexer.getEvents({ + where, + relations: ['block'], + // TODO: Use querybuilder to order by block number + order: { block: 'ASC', index: 'ASC' }, + take: resultLimit + 1 + }); // Limit number of results can be returned by a single query if (events.length > resultLimit) { @@ -229,10 +230,82 @@ const parseEthGetLogsBlockTag = async (indexer: IndexerInterface, blockTag: stri throw new ErrorWithCode(CODE_INVALID_PARAMS, ERROR_INVALID_BLOCK_TAG); }; +const buildAddressFilter = (address: any, where: FindConditions): void => { + if (Array.isArray(address)) { + // Validate input addresses + address.forEach((add: string) => { + if (!utils.isHexString(add, 20)) { + throw new ErrorWithCode(CODE_INVALID_PARAMS, `${ERROR_INVALID_CONTRACT_ADDRESS}: expected hex string of size 20`); + } + }); + + if (address.length > 0) { + where.contract = In(address); + } + } else { + // Validate input address + if (!utils.isHexString(address, 20)) { + throw new ErrorWithCode(CODE_INVALID_PARAMS, `${ERROR_INVALID_CONTRACT_ADDRESS}: expected hex string of size 20`); + } + + where.contract = Equal(address); + } +}; + +type TopicColumn = 'topic0' | 'topic1' | 'topic2' | 'topic3'; + +const buildTopicsFilter = (topics: any, where: FindConditions): void => { + // Check that topics is an array of size <= 4 + if (!Array.isArray(topics)) { + throw new ErrorWithCode(CODE_INVALID_PARAMS, ERROR_INVALID_TOPICS); + } + + if (topics.length > 4) { + throw new ErrorWithCode(CODE_INVALID_PARAMS, `${ERROR_INVALID_TOPICS}: exceeds max topics`); + } + + for (let i = 0; i < topics.length; i++) { + addTopicCondition(topics[i], `topic${i}` as TopicColumn, where); + } +}; + +const addTopicCondition = ( + topicFilter: string[] | string, + topicIndex: TopicColumn, + where: FindConditions +): any => { + if (Array.isArray(topicFilter)) { + // Validate input topics + topicFilter.forEach((topic: string) => { + if (!utils.isHexString(topic, 32)) { + throw new ErrorWithCode(CODE_INVALID_PARAMS, `${ERROR_INVALID_TOPICS}: expected hex string of size 32 for ${topicIndex}`); + } + }); + + if (topicFilter.length > 0) { + where[topicIndex] = In(topicFilter); + } + } else { + // Validate input address + if (!utils.isHexString(topicFilter, 32)) { + throw new ErrorWithCode(CODE_INVALID_PARAMS, `${ERROR_INVALID_TOPICS}: expected hex string of size 32 for ${topicIndex}`); + } + + where[topicIndex] = Equal(topicFilter); + } +}; + const transformEventsToLogs = async (events: Array): Promise => { return events.map(event => { const parsedExtraInfo = JSON.parse(event.extraInfo); + const topics: string[] = []; + [event.topic0, event.topic1, event.topic2, event.topic3].forEach(topic => { + if (topic) { + topics.push(topic); + } + }); + return { address: event.contract.toLowerCase(), blockHash: event.block.blockHash, @@ -240,8 +313,8 @@ const transformEventsToLogs = async (events: Array): Promise void) => { // Convert all GET requests to POST to avoid getting rejected from jayson server middleware @@ -124,8 +126,8 @@ export const createAndStartServer = async ( httpServer.listen(port, host, () => { log(`GQL server is listening on http://${host}:${port}${server.graphqlPath}`); - if (serverConfig.enableEthRPCServer) { - log(`ETH JSON RPC server is listening on http://${host}:${port}${ETH_RPC_PATH}`); + if (serverConfig.ethRPC?.enabled) { + log(`ETH JSON RPC server is listening on http://${host}:${port}${rpcPath}`); } }); diff --git a/packages/util/src/types.ts b/packages/util/src/types.ts index f799fde1e..4ab61c29a 100644 --- a/packages/util/src/types.ts +++ b/packages/util/src/types.ts @@ -62,6 +62,11 @@ export interface EventInterface { index: number; contract: string; eventName: string; + topic0: string; + topic1: string | null; + topic2: string | null; + topic3: string | null; + data: string; eventInfo: string; extraInfo: string; proof: string;