From b46d8816b5de42f84b9a4bcd46eb2081f79776ec Mon Sep 17 00:00:00 2001 From: prathamesh0 <42446521+prathamesh0@users.noreply.github.com> Date: Fri, 13 Sep 2024 12:44:00 +0530 Subject: [PATCH] Add ETH RPC API to watcher server (#535) * Add ETH RPC API to watcher server * Add eth_call API handler * Add error handling to eth_call handler * Parse block tag in eth_call handler * Add a flag to enable ETH RPC server * Fix lint errors * Update block tag parsing --- packages/cli/src/server.ts | 8 +- packages/graph-node/test/utils/indexer.ts | 8 +- packages/util/package.json | 1 + packages/util/src/config.ts | 3 + packages/util/src/eth-rpc-handlers.ts | 134 ++++++++++++++++++++++ packages/util/src/index.ts | 1 + packages/util/src/server.ts | 23 +++- packages/util/src/types.ts | 3 +- yarn.lock | 66 ++++++++++- 9 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 packages/util/src/eth-rpc-handlers.ts diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 68e7b169..98c64f9a 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -32,7 +32,8 @@ import { readParty, UpstreamConfig, fillBlocks, - createGQLLogger + createGQLLogger, + createEthRPCHandlers } from '@cerc-io/util'; import { TypeSource } from '@graphql-tools/utils'; import type { @@ -285,6 +286,7 @@ export class ServerCmd { const jobQueue = this._baseCmd.jobQueue; const indexer = this._baseCmd.indexer; const eventWatcher = this._baseCmd.eventWatcher; + const ethProvider = this._baseCmd.ethProvider; assert(config); assert(jobQueue); @@ -317,9 +319,11 @@ export class ServerCmd { const gqlLogger = createGQLLogger(config.server.gql.logDir); const resolvers = await createResolvers(indexer, eventWatcher, gqlLogger); + const ethRPCHandlers = await createEthRPCHandlers(indexer, ethProvider); + // Create an Express app const app: Application = express(); - const server = await createAndStartServer(app, typeDefs, resolvers, config.server, paymentsManager); + const server = await createAndStartServer(app, typeDefs, resolvers, ethRPCHandlers, config.server, paymentsManager); await startGQLMetricsServer(config); diff --git a/packages/graph-node/test/utils/indexer.ts b/packages/graph-node/test/utils/indexer.ts index e31c3b56..3930e97e 100644 --- a/packages/graph-node/test/utils/indexer.ts +++ b/packages/graph-node/test/utils/indexer.ts @@ -2,7 +2,7 @@ import assert from 'assert'; import { DeepPartial, FindConditions, FindManyOptions } from 'typeorm'; -import { providers } from 'ethers'; +import { ethers } from 'ethers'; import { IndexerInterface, @@ -28,6 +28,8 @@ import { GetStorageAt, getStorageValue, MappingKey, StorageLayout } from '@cerc- export class Indexer implements IndexerInterface { _getStorageAt: GetStorageAt; _storageLayoutMap: Map = new Map(); + _contractMap: Map = new Map(); + eventSignaturesMap: Map = new Map(); constructor (ethClient: EthClient, storageLayoutMap?: Map) { @@ -50,6 +52,10 @@ export class Indexer implements IndexerInterface { return this._storageLayoutMap; } + get contractMap (): Map { + return this._contractMap; + } + async init (): Promise { return undefined; } diff --git a/packages/util/package.json b/packages/util/package.json index 586573fe..130f5e57 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -36,6 +36,7 @@ "it-length-prefixed": "^8.0.4", "it-pipe": "^2.0.5", "it-pushable": "^3.1.2", + "jayson": "^4.1.2", "js-yaml": "^4.1.0", "json-bigint": "^1.0.0", "lodash": "^4.17.21", diff --git a/packages/util/src/config.ts b/packages/util/src/config.ts index b36fbade..9567a87a 100644 --- a/packages/util/src/config.ts +++ b/packages/util/src/config.ts @@ -252,6 +252,9 @@ export interface ServerConfig { // Flag to specify whether RPC endpoint supports block hash as block tag parameter // https://ethereum.org/en/developers/docs/apis/json-rpc/#default-block rpcSupportsBlockHashParam: boolean; + + // Enable ETH JSON RPC server at /rpc + enableEthRPCServer: boolean; } export interface FundingAmountsConfig { diff --git a/packages/util/src/eth-rpc-handlers.ts b/packages/util/src/eth-rpc-handlers.ts new file mode 100644 index 00000000..406a6a20 --- /dev/null +++ b/packages/util/src/eth-rpc-handlers.ts @@ -0,0 +1,134 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { utils } from 'ethers'; + +import { JsonRpcProvider } from '@ethersproject/providers'; + +import { IndexerInterface } from './types'; + +const CODE_INVALID_PARAMS = -32602; +const CODE_INTERNAL_ERROR = -32603; +const CODE_SERVER_ERROR = -32000; + +const ERROR_CONTRACT_MAP_NOT_SET = 'Contract map not set'; +const ERROR_CONTRACT_ABI_NOT_FOUND = 'Contract ABI not found'; +const ERROR_CONTRACT_INSUFFICIENT_PARAMS = 'Insufficient params'; +const ERROR_CONTRACT_NOT_RECOGNIZED = 'Contract not recognized'; +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_BLOCK_NOT_FOUND = 'Block not found'; + +const DEFAULT_BLOCK_TAG = 'latest'; + +class ErrorWithCode extends Error { + code: number; + constructor (code: number, message: string) { + super(message); + this.code = code; + } +} + +export const createEthRPCHandlers = async ( + indexer: IndexerInterface, + ethProvider: JsonRpcProvider +): Promise => { + return { + eth_blockNumber: async (args: any, callback: any) => { + const syncStatus = await indexer.getSyncStatus(); + const result = syncStatus ? `0x${syncStatus.latestProcessedBlockNumber.toString(16)}` : '0x'; + + callback(null, result); + }, + + eth_call: async (args: any, callback: any) => { + try { + if (!indexer.contractMap) { + throw new ErrorWithCode(CODE_INTERNAL_ERROR, ERROR_CONTRACT_MAP_NOT_SET); + } + + if (args.length === 0) { + throw new ErrorWithCode(CODE_INVALID_PARAMS, ERROR_CONTRACT_INSUFFICIENT_PARAMS); + } + + const { to, data } = args[0]; + const blockTag = args.length > 1 ? args[1] : DEFAULT_BLOCK_TAG; + + const blockHash = await parseBlockTag(indexer, ethProvider, blockTag); + + const watchedContract = indexer.getWatchedContracts().find(contract => contract.address === to); + if (!watchedContract) { + throw new ErrorWithCode(CODE_INVALID_PARAMS, ERROR_CONTRACT_NOT_RECOGNIZED); + } + + const contractInterface = indexer.contractMap.get(watchedContract.kind); + if (!contractInterface) { + throw new ErrorWithCode(CODE_INTERNAL_ERROR, ERROR_CONTRACT_ABI_NOT_FOUND); + } + + // Slice out method signature from data + const functionSelector = data.slice(0, 10); + + // Find the matching function from the ABI + const functionFragment = contractInterface.getFunction(functionSelector); + if (!functionFragment) { + throw new ErrorWithCode(CODE_INVALID_PARAMS, ERROR_CONTRACT_METHOD_NOT_FOUND); + } + + // Decode the data based on the matched function + const decodedData = contractInterface.decodeFunctionData(functionFragment, data); + + const functionName = functionFragment.name; + const indexerMethod = (indexer as any)[functionName].bind(indexer); + if (!indexerMethod) { + throw new ErrorWithCode(CODE_SERVER_ERROR, ERROR_METHOD_NOT_IMPLEMENTED); + } + + const result = await indexerMethod(blockHash, to, ...decodedData); + const encodedResult = contractInterface.encodeFunctionResult(functionFragment, Array.isArray(result.value) ? result.value : [result.value]); + + callback(null, encodedResult); + } catch (error: any) { + let callBackError; + if (error instanceof ErrorWithCode) { + callBackError = { code: error.code, message: error.message }; + } else { + callBackError = { code: CODE_SERVER_ERROR, message: error.message }; + } + + callback(callBackError); + } + }, + + eth_getLogs: async (args: any, callback: any) => { + // TODO: Implement + } + }; +}; + +const parseBlockTag = async (indexer: IndexerInterface, ethProvider: JsonRpcProvider, blockTag: string): Promise => { + if (utils.isHexString(blockTag)) { + // Return value if hex string is of block hash length + if (utils.hexDataLength(blockTag) === 32) { + return blockTag; + } + + // Treat hex value as a block number + const block = await ethProvider.getBlock(blockTag); + if (block === null) { + throw new ErrorWithCode(CODE_INVALID_PARAMS, ERROR_BLOCK_NOT_FOUND); + } + + return block.hash; + } + + if (blockTag === DEFAULT_BLOCK_TAG) { + const syncStatus = await indexer.getSyncStatus(); + if (!syncStatus) { + throw new ErrorWithCode(CODE_INTERNAL_ERROR, 'SyncStatus not found'); + } + + return syncStatus.latestProcessedBlockHash; + } + + throw new ErrorWithCode(CODE_INVALID_PARAMS, ERROR_INVALID_BLOCK_TAG); +}; diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index b0dc8c1f..76620754 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -28,3 +28,4 @@ export * from './eth'; export * from './consensus'; export * from './validate-config'; export * from './logger'; +export * from './eth-rpc-handlers'; diff --git a/packages/util/src/server.ts b/packages/util/src/server.ts index 0094b60a..d7acc26a 100644 --- a/packages/util/src/server.ts +++ b/packages/util/src/server.ts @@ -11,6 +11,8 @@ import debug from 'debug'; import responseCachePlugin from 'apollo-server-plugin-response-cache'; import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache'; import queue from 'express-queue'; +import jayson from 'jayson'; +import { json as jsonParser } from 'body-parser'; import { TypeSource } from '@graphql-tools/utils'; import { makeExecutableSchema } from '@graphql-tools/schema'; @@ -22,11 +24,13 @@ import { PaymentsManager, paymentsPlugin } from './payments'; const log = debug('vulcanize:server'); const DEFAULT_GQL_PATH = '/graphql'; +const ETH_RPC_PATH = '/rpc'; export const createAndStartServer = async ( app: Application, typeDefs: TypeSource, resolvers: any, + ethRPCHandlers: any, serverConfig: ServerConfig, paymentsManager?: PaymentsManager ): Promise => { @@ -98,8 +102,25 @@ export const createAndStartServer = async ( path: gqlPath }); + if (serverConfig.enableEthRPCServer) { + // Create a JSON-RPC server to handle ETH RPC calls + const rpcServer = jayson.Server(ethRPCHandlers); + + // Mount the JSON-RPC server to ETH_RPC_PATH + app.use( + ETH_RPC_PATH, + jsonParser(), + // TODO: Handle GET requests as well to match Geth's behaviour + rpcServer.middleware() + ); + } + httpServer.listen(port, host, () => { - log(`Server is listening on ${host}:${port}${server.graphqlPath}`); + 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}`); + } }); return server; diff --git a/packages/util/src/types.ts b/packages/util/src/types.ts index ee49108c..5afca668 100644 --- a/packages/util/src/types.ts +++ b/packages/util/src/types.ts @@ -3,7 +3,7 @@ // import { Connection, DeepPartial, EntityTarget, FindConditions, FindManyOptions, ObjectLiteral, QueryRunner } from 'typeorm'; -import { Transaction } from 'ethers'; +import { ethers, Transaction } from 'ethers'; import { MappingKey, StorageLayout } from '@cerc-io/solidity-mapper'; @@ -161,6 +161,7 @@ export interface IndexerInterface { readonly serverConfig: ServerConfig readonly upstreamConfig: UpstreamConfig readonly storageLayoutMap: Map + readonly contractMap: Map // eslint-disable-next-line no-use-before-define readonly graphWatcher?: GraphWatcherInterface init (): Promise diff --git a/yarn.lock b/yarn.lock index 7feecaab..b50330e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3635,6 +3635,13 @@ dependencies: "@types/node" "*" +"@types/connect@^3.4.33": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + "@types/cors@2.8.12": version "2.8.12" resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080" @@ -3848,6 +3855,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.60.tgz#35f3d6213daed95da7f0f73e75bcc6980e90597b" integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw== +"@types/node@^12.12.54": + version "12.20.55" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240" + integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ== + "@types/node@^12.12.6": version "12.20.13" resolved "https://registry.npmjs.org/@types/node/-/node-12.20.13.tgz" @@ -3954,6 +3966,13 @@ resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== +"@types/ws@^7.4.4": + version "7.4.7" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702" + integrity sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww== + dependencies: + "@types/node" "*" + "@types/ws@^8.5.3": version "8.5.4" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.4.tgz#bb10e36116d6e570dd943735f86c933c1587b8a5" @@ -4151,7 +4170,7 @@ resolved "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz" integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== -JSONStream@^1.0.4: +JSONStream@^1.0.4, JSONStream@^1.3.5: version "1.3.5" resolved "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz" integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ== @@ -7398,6 +7417,18 @@ es6-object-assign@^1.1.0: resolved "https://registry.yarnpkg.com/es6-object-assign/-/es6-object-assign-1.1.0.tgz#c2c3582656247c39ea107cb1e6652b6f9f24523c" integrity sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw== +es6-promise@^4.0.3: + version "4.2.8" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" + integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== + +es6-promisify@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" + integrity sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ== + dependencies: + es6-promise "^4.0.3" + es6-symbol@^3.1.1, es6-symbol@~3.1.3: version "3.1.3" resolved "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz" @@ -8293,6 +8324,11 @@ extsprintf@^1.2.0: resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.0.tgz" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= +eyes@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0" + integrity sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ== + fake-merkle-patricia-tree@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/fake-merkle-patricia-tree/-/fake-merkle-patricia-tree-1.0.1.tgz" @@ -10422,6 +10458,11 @@ isobject@^3.0.0, isobject@^3.0.1: resolved "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz" integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= +isomorphic-ws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc" + integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w== + isstream@~0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz" @@ -10651,6 +10692,24 @@ jackspeak@^2.3.5: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jayson@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/jayson/-/jayson-4.1.2.tgz#443c26a8658703e0b2e881117b09395d88b6982e" + integrity sha512-5nzMWDHy6f+koZOuYsArh2AXs73NfWYVlFyJJuCedr93GpY+Ku8qq10ropSXVfHK+H0T6paA88ww+/dV+1fBNA== + dependencies: + "@types/connect" "^3.4.33" + "@types/node" "^12.12.54" + "@types/ws" "^7.4.4" + JSONStream "^1.3.5" + commander "^2.20.3" + delay "^5.0.0" + es6-promisify "^5.0.0" + eyes "^0.1.8" + isomorphic-ws "^4.0.1" + json-stringify-safe "^5.0.1" + uuid "^8.3.2" + ws "^7.5.10" + js-sdsl@^4.1.4: version "4.4.0" resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.4.0.tgz#8b437dbe642daa95760400b602378ed8ffea8430" @@ -17029,6 +17088,11 @@ ws@^7.4.6: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== +ws@^7.5.10: + version "7.5.10" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" + integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== + ws@^8.11.0, ws@^8.12.1, ws@^8.4.0: version "8.13.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0"