From e3d397c815e6fb770d313e429c455f5a1831412a Mon Sep 17 00:00:00 2001 From: Filip Pajic <42151917+pajicf@users.noreply.github.com> Date: Fri, 15 Dec 2023 15:55:01 +0100 Subject: [PATCH] core: Price Updating - revert history observation (#6) * created the etherscan service * chainlink service created * added tx input parser for oracle service * added reverts to prices redux * created reverts api route * reverts history cron job created * lint fixes --- .env.example | 4 ++ src/api/v1/definitions/reverts.route.d.ts | 31 ++++++++++ src/api/v1/index.ts | 5 +- src/api/v1/routes/reverts.route.ts | 24 ++++++++ src/config/index.ts | 10 +++- src/entities/revert.entity.ts | 24 ++++++++ src/jobs/app.jobs.ts | 4 +- src/jobs/price.jobs.ts | 47 ++++++++++++++- src/redux/prices/prices.redux.actions.ts | 19 ++++++- src/redux/prices/prices.redux.reducer.ts | 23 +++++++- src/redux/prices/prices.redux.types.ts | 18 +++++- src/services/chainlink.service.ts | 20 +++++++ src/services/etherscan/etherscan.service.ts | 57 +++++++++++++++++++ .../etherscan/etherscan.service.types.ts | 34 +++++++++++ src/services/oracle/oracle.service.ts | 11 +++- src/services/oracle/oracle.service.types.ts | 5 ++ src/services/web3.service.ts | 10 +++- 17 files changed, 336 insertions(+), 10 deletions(-) create mode 100644 src/api/v1/definitions/reverts.route.d.ts create mode 100644 src/api/v1/routes/reverts.route.ts create mode 100644 src/entities/revert.entity.ts create mode 100644 src/services/chainlink.service.ts create mode 100644 src/services/etherscan/etherscan.service.ts create mode 100644 src/services/etherscan/etherscan.service.types.ts diff --git a/.env.example b/.env.example index 9ac2c04..18c7299 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,10 @@ COINGECKO_BASE_URL=https://api.coingecko.com/api/v3/ COINGECKO_API_KEY= COINGECKO_API_KEY_QUERY_NAME=x_cg_demo_api_key +ETHERSCAN_BASE_URL= +ETHERSCAN_API_KEY= +ETHERSCAN_API_KEY_QUERY_NAME= + ALCHEMY_URL= PRIVATE_KEY= diff --git a/src/api/v1/definitions/reverts.route.d.ts b/src/api/v1/definitions/reverts.route.d.ts new file mode 100644 index 0000000..ba8bcc3 --- /dev/null +++ b/src/api/v1/definitions/reverts.route.d.ts @@ -0,0 +1,31 @@ +import { NextFunction, Request as ExpressRequest, Response as ExpressResponse } from "express"; +import { IResponseSuccess } from "../../../utils/response.util"; +import { EmptyObject, ParamsDictionary } from "../../../types/util.types"; +import { RevertEntity } from "../../../entities/revert.entity"; + +export enum ERevertsRoute { + GetRevertList = "GetRevertList" +} + +declare namespace RevertsRouteDefinitions { + type ResponseBody = + T extends ERevertsRoute.GetRevertList ? RevertEntity[] : + EmptyObject + + type RequestBody = // eslint-disable-line @typescript-eslint/no-unused-vars + EmptyObject; + + type RequestQueries = // eslint-disable-line @typescript-eslint/no-unused-vars + EmptyObject + + type RequestParams = // eslint-disable-line @typescript-eslint/no-unused-vars + EmptyObject + + type Response = ExpressResponse>> + + type Request = ExpressRequest & ParamsDictionary, IResponseSuccess>, RequestBody, RequestQueries> + + type RouteMethod = (request: Request, response: Response, next: NextFunction) => Promise; +} + +export default RevertsRouteDefinitions; \ No newline at end of file diff --git a/src/api/v1/index.ts b/src/api/v1/index.ts index b59c252..5c7b098 100644 --- a/src/api/v1/index.ts +++ b/src/api/v1/index.ts @@ -3,14 +3,17 @@ import { error } from "./middlewares/error.middleware"; import StatusRoute from "./routes/status.route"; import TickersRoute from "./routes/tickers.route"; import TickersValidator from "./validators/tickers.validator"; +import RevertsRoute from "./routes/reverts.route"; const v1 = Router(); +v1.get("/status", StatusRoute.getStatus); + v1.get("/tickers/", TickersRoute.getTickerList); v1.get("/tickers/:tickerSymbol", TickersValidator.validateGetTicker, TickersRoute.getTicker); v1.get("/tickers/:tickerSymbol/history", TickersValidator.validateGetTickerPriceHistory, TickersRoute.getTickerPriceHistory); -v1.get("/status", StatusRoute.getStatus); +v1.get("/reverts/", RevertsRoute.getRevertList); v1.use(error); diff --git a/src/api/v1/routes/reverts.route.ts b/src/api/v1/routes/reverts.route.ts new file mode 100644 index 0000000..a650185 --- /dev/null +++ b/src/api/v1/routes/reverts.route.ts @@ -0,0 +1,24 @@ +import RevertsRouteDefinitions, { ERevertsRoute } from "../definitions/reverts.route"; +import store from "../../../redux/store"; +import { RevertEntity, revertEntityFromReduxState } from "../../../entities/revert.entity"; +import { APIResponse } from "../../../utils/response.util"; + +class RevertsRoute { + public static getRevertList: RevertsRouteDefinitions.RouteMethod = async (request, response, next) => { + try { + const rootState = store.getState(); + const txHashes = Object.keys(rootState.prices.reverts); + + const revertList: RevertEntity[] = []; + txHashes.forEach(txHash => { + revertList.push(revertEntityFromReduxState(txHash, rootState)); + }); + + return response.status(200).json(APIResponse.success(revertList)); + } catch (error) { + next(error); + } + }; +} + +export default RevertsRoute; \ No newline at end of file diff --git a/src/config/index.ts b/src/config/index.ts index e654aad..21b3a72 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -26,7 +26,10 @@ const { PRIVATE_KEY, TICKER_USD_FEED_REGISTRY, TICKER_PRICE_STORAGE, - CONTRACTS_DEPLOYMENT_BLOCK + CONTRACTS_DEPLOYMENT_BLOCK, + ETHERSCAN_BASE_URL, + ETHERSCAN_API_KEY, + ETHERSCAN_API_KEY_QUERY_NAME } = process.env; const ENV: ApplicationEnv = NODE_ENV as ApplicationEnv || ApplicationEnv.DEVELOPMENT; @@ -43,5 +46,8 @@ export const CONFIG = { PRIVATE_KEY, TICKER_USD_FEED_REGISTRY, TICKER_PRICE_STORAGE, - CONTRACTS_DEPLOYMENT_BLOCK: Number(CONTRACTS_DEPLOYMENT_BLOCK) + CONTRACTS_DEPLOYMENT_BLOCK: Number(CONTRACTS_DEPLOYMENT_BLOCK), + ETHERSCAN_BASE_URL, + ETHERSCAN_API_KEY, + ETHERSCAN_API_KEY_QUERY_NAME }; diff --git a/src/entities/revert.entity.ts b/src/entities/revert.entity.ts new file mode 100644 index 0000000..89ba6a2 --- /dev/null +++ b/src/entities/revert.entity.ts @@ -0,0 +1,24 @@ +import { RootState } from "../redux/redux.types"; +import { Nullable } from "../types/util.types"; + +export type RevertEntity = { + txHash: string, + tickerSymbol: string, + sentPrice: number, + chainlinkPrice: number +} + +export function revertEntityFromReduxState(txHash: string, state: RootState): Nullable { + const reverts = state.prices.reverts[txHash]; + + if (reverts) { + return { + txHash: txHash, + tickerSymbol: reverts.tickerSymbol, + chainlinkPrice: reverts.chainlinkPrice, + sentPrice: reverts.sentPrice + }; + } else { + return undefined; + } +} \ No newline at end of file diff --git a/src/jobs/app.jobs.ts b/src/jobs/app.jobs.ts index e7b3be7..9f58cfc 100644 --- a/src/jobs/app.jobs.ts +++ b/src/jobs/app.jobs.ts @@ -1,5 +1,5 @@ import { initTickerState, setupTickerFetchingJob } from "./ticker.jobs"; -import { initOnchainPriceState, setupOffchainPriceFetchingJob } from "./price.jobs"; +import { initOnchainPriceState, setupOffchainPriceFetchingJob, setupPriceUpdateRevertObserverJob } from "./price.jobs"; import logger from "../utils/logger.util"; import store from "../redux/store"; @@ -13,6 +13,8 @@ const initAppState = async () => { export const initApp = async () => { await initAppState(); + await setupPriceUpdateRevertObserverJob(); + await setupTickerFetchingJob(); await setupOffchainPriceFetchingJob(); diff --git a/src/jobs/price.jobs.ts b/src/jobs/price.jobs.ts index e382847..3f37899 100644 --- a/src/jobs/price.jobs.ts +++ b/src/jobs/price.jobs.ts @@ -2,12 +2,22 @@ import CoinGeckoService from "../services/coingecko/coingecko.service"; import CronService from "../services/cron.service"; import store from "../redux/store"; import { getCoingeckoIdByChainlinkTicker } from "../constants/coingecko"; -import { setCurrentOffchainPrice, setCurrentOnchainPrice } from "../redux/prices/prices.redux.actions"; +import { + addPriceRevertedTx, + setCurrentOffchainPrice, + setCurrentOnchainPrice +} from "../redux/prices/prices.redux.actions"; import OracleService from "../services/oracle/oracle.service"; import logger from "../utils/logger.util"; import { RootSocket } from "../index"; +import { CONFIG } from "../config"; +import EtherscanService from "../services/etherscan/etherscan.service"; +import ChainlinkService from "../services/chainlink.service"; +import Web3Service from "../services/web3.service"; const coinGecko = new CoinGeckoService(); +const etherscan = new EtherscanService(); + const oracleService = new OracleService(); export const setupOffchainPriceFetchingJob = async () => { @@ -56,4 +66,39 @@ export const setupOnchainPriceFetchingJobFor = async (tickerSymbol: string) => { updateReduxTickerOnchainPrice(tickerSymbol, tickerPriceData.newPrice); RootSocket.emitEvent("TickerPriceUpdated", [tickerSymbol, tickerPriceData.newPrice]); }); +}; + +let lastBlockCache: number = CONFIG.CONTRACTS_DEPLOYMENT_BLOCK; +const fetchPriceUpdatingRevertHistory = async () => { + logger.log(`Trying to find reverts since block number: ${lastBlockCache}`); + + const currentBlockNumber = await Web3Service.provider.getBlockNumber(); + const revertedTransactions = await etherscan.getRevertedTxHistory(CONFIG.TICKER_PRICE_STORAGE, lastBlockCache); + lastBlockCache = currentBlockNumber; + + for (let i = 0; i < revertedTransactions.length; i++) { + const tx = revertedTransactions[i]; + logger.log(`Revert found with tx hash ${tx.hash}`); + + const parsedTxInputs = oracleService.parseInputData(tx.input); + const chainlinkFeedAddress = store.getState().tickers.chainlinkFeed[parsedTxInputs.ticker]; + + // @TODO Handle invalid ticker param sent + if (!chainlinkFeedAddress) { + continue; + } + + const chainlinkFeed = new ChainlinkService(chainlinkFeedAddress); + const chainlinkPrice = await chainlinkFeed.getPriceForBlock(Number(tx.blockNumber)); + + store.dispatch(addPriceRevertedTx(tx.hash, parsedTxInputs.ticker, Number(parsedTxInputs.price), Number(chainlinkPrice))); + } +}; + +export const setupPriceUpdateRevertObserverJob = async () => { + const minutesRefreshRate = 30; + const runOnInit = true; + logger.log("Setting up revert history job"); + + CronService.scheduleRecurringJob(fetchPriceUpdatingRevertHistory, minutesRefreshRate, runOnInit); }; \ No newline at end of file diff --git a/src/redux/prices/prices.redux.actions.ts b/src/redux/prices/prices.redux.actions.ts index 0149f0a..fc7e218 100644 --- a/src/redux/prices/prices.redux.actions.ts +++ b/src/redux/prices/prices.redux.actions.ts @@ -1,4 +1,9 @@ -import { EPricesReduxActions, SetCurrentOffchainPriceAction, SetCurrentOnchainPriceAction } from "./prices.redux.types"; +import { + AddPriceRevertedTx, + EPricesReduxActions, + SetCurrentOffchainPriceAction, + SetCurrentOnchainPriceAction +} from "./prices.redux.types"; export function setCurrentOnchainPrice(tickerSymbol: string, onchainPrice: number): SetCurrentOnchainPriceAction { return { @@ -18,4 +23,16 @@ export function setCurrentOffchainPrice(tickerSymbol: string, offchainPrice: num offchainPrice } }; +} + +export function addPriceRevertedTx(txHash: string, tickerSymbol: string, sentPrice: number, chainlinkPrice: number): AddPriceRevertedTx { + return { + type: EPricesReduxActions.ADD_PRICE_REVERTED_TX, + payload: { + txHash, + tickerSymbol, + sentPrice, + chainlinkPrice + } + }; } \ No newline at end of file diff --git a/src/redux/prices/prices.redux.reducer.ts b/src/redux/prices/prices.redux.reducer.ts index 41384e5..68b7d32 100644 --- a/src/redux/prices/prices.redux.reducer.ts +++ b/src/redux/prices/prices.redux.reducer.ts @@ -2,7 +2,8 @@ import { EPricesReduxActions, PricesReduxActions, PricesReduxReducerState } from import { Reducer } from "redux"; const initialState: PricesReduxReducerState = { - current: {} + current: {}, + reverts: {} }; const pricesReduxReducer: Reducer = (state = initialState, action) => { @@ -35,6 +36,26 @@ const pricesReduxReducer: Reducer = } }; } + case EPricesReduxActions.ADD_PRICE_REVERTED_TX: { + const { + txHash, + tickerSymbol, + sentPrice, + chainlinkPrice + } = action.payload; + + return { + ...state, + reverts: { + ...state.reverts, + [txHash]: { + tickerSymbol, + sentPrice, + chainlinkPrice + } + } + }; + } default: { return initialState; } diff --git a/src/redux/prices/prices.redux.types.ts b/src/redux/prices/prices.redux.types.ts index e21a682..0d37bbf 100644 --- a/src/redux/prices/prices.redux.types.ts +++ b/src/redux/prices/prices.redux.types.ts @@ -3,6 +3,7 @@ import { ReduxAction } from "../redux.types"; export enum EPricesReduxActions { SET_CURRENT_ONCHAIN_PRICE = "SET_CURRENT_ONCHAIN_PRICE", SET_CURRENT_OFFCHAIN_PRICE = "SET_CURRENT_OFFCHAIN_PRICE", + ADD_PRICE_REVERTED_TX = "ADD_PRICE_REVERTED_TX" } export type SetCurrentOnchainPriceAction = ReduxAction +export type AddPriceRevertedTx = ReduxAction + export type PricesReduxActions = SetCurrentOnchainPriceAction | - SetCurrentOffchainPriceAction; + SetCurrentOffchainPriceAction | + AddPriceRevertedTx; export type PricesReduxReducerState = { current: { @@ -26,4 +35,11 @@ export type PricesReduxReducerState = { offchain?: number; } } + reverts: { + [txHash: string]: { + tickerSymbol: string, + sentPrice: number, + chainlinkPrice: number + } + } } \ No newline at end of file diff --git a/src/services/chainlink.service.ts b/src/services/chainlink.service.ts new file mode 100644 index 0000000..a40b41e --- /dev/null +++ b/src/services/chainlink.service.ts @@ -0,0 +1,20 @@ +import { ChainlinkAggregatorV3Abi } from "../contracts"; +import Web3Service from "./web3.service"; + +class ChainlinkService { + private _aggregatorContract: ChainlinkAggregatorV3Abi; + + constructor(aggregatorAddress: string) { + this._aggregatorContract = Web3Service.getChainlinkPriceFeedContract(aggregatorAddress); + } + + public async getPriceForBlock(blockNumber: number) { + const result = await this._aggregatorContract.latestRoundData({ + blockTag: blockNumber + }); + + return result.answer; + } +} + +export default ChainlinkService; \ No newline at end of file diff --git a/src/services/etherscan/etherscan.service.ts b/src/services/etherscan/etherscan.service.ts new file mode 100644 index 0000000..bf0feac --- /dev/null +++ b/src/services/etherscan/etherscan.service.ts @@ -0,0 +1,57 @@ +import RestService from "../rest.service"; +import { CONFIG } from "../../config"; +import { EAuthenticationType } from "../../types/auth.types"; +import { EtherscanContractTxHistoryResponse, EtherscanRevertedTxHistory } from "./etherscan.service.types"; + +class EtherscanService extends RestService { + constructor() { + super({ + baseUrl: CONFIG.ETHERSCAN_BASE_URL, + authConfig: { + type: EAuthenticationType.QUERY_PARAM, + paramName: CONFIG.ETHERSCAN_API_KEY_QUERY_NAME, + paramValue: CONFIG.ETHERSCAN_API_KEY + } + }); + } + + public async getContractTxHistory(contractAddress: string, startBlock: number): Promise { + const response = await this.get({ + url: "api", + config: { + params: { + module: "account", + action: "txlist", + address: contractAddress, + startBlock: startBlock, + sort: "asc" + } + } + }); + + return response.data; + } + + public async getRevertedTxHistory(contractAddress: string, startBlock: number): Promise { + const apiResponse = await this.getContractTxHistory(contractAddress, startBlock); + const results: EtherscanRevertedTxHistory = []; + + if (apiResponse.status == "1" && apiResponse.result.length > 0) { + apiResponse.result.forEach(etherscanResponse => { + if (etherscanResponse.isError == "1") { + results.push({ + blockNumber: etherscanResponse.blockNumber, + hash: etherscanResponse.hash, + input: etherscanResponse.input + }); + } + }); + } else { + return []; + } + + return results; + } +} + +export default EtherscanService; \ No newline at end of file diff --git a/src/services/etherscan/etherscan.service.types.ts b/src/services/etherscan/etherscan.service.types.ts new file mode 100644 index 0000000..31c1f08 --- /dev/null +++ b/src/services/etherscan/etherscan.service.types.ts @@ -0,0 +1,34 @@ +export type EtherscanResponse = { + status: string, + message: string, + result: T +} + +export type EtherscanContractTxHistoryResponse = EtherscanResponse<{ + blockNumber: string, + timeStamp: string, + hash: string, + nonce: string, + blockHash: string, + transactionIndex: string, + from: string, + to: string, + value: string, + gas: string, + gasPrice: string, + isError: string, + txreceipt_status: string, + input: string, + contractAddress: string, + cumulativeGasUsed: string, + gasUsed: string, + confirmations: string, + methodId: string, + functionName: string +}[]> + +export type EtherscanRevertedTxHistory = { + blockNumber: string, + hash: string, + input: string +}[] \ No newline at end of file diff --git a/src/services/oracle/oracle.service.ts b/src/services/oracle/oracle.service.ts index 171e33a..6a3e880 100644 --- a/src/services/oracle/oracle.service.ts +++ b/src/services/oracle/oracle.service.ts @@ -2,7 +2,7 @@ import Web3Service from "../web3.service"; import { CONFIG } from "../../config"; import logger from "../../utils/logger.util"; import { TypedEventLog } from "../../contracts/common"; -import { TickerPriceData } from "./oracle.service.types"; +import { SetFunctionArguments, TickerPriceData } from "./oracle.service.types"; class OracleService { private _tickerPriceContract; @@ -67,6 +67,15 @@ class OracleService { }); } + public parseInputData(input: string): SetFunctionArguments { + const txDescription = this._tickerPriceContract.interface.parseTransaction({ data: input }); + + return { + ticker: txDescription.args[0], + price: txDescription.args[1] + }; + } + private parseTickerPriceUpdatedEvent(event: TypedEventLog): TickerPriceData { const args = event.args as [string, number]; const newPrice = args[1]; diff --git a/src/services/oracle/oracle.service.types.ts b/src/services/oracle/oracle.service.types.ts index e182eda..5f22a3b 100644 --- a/src/services/oracle/oracle.service.types.ts +++ b/src/services/oracle/oracle.service.types.ts @@ -1,3 +1,8 @@ export type TickerPriceData = { newPrice: number; +} + +export type SetFunctionArguments = { + ticker: string, + price: bigint } \ No newline at end of file diff --git a/src/services/web3.service.ts b/src/services/web3.service.ts index 2fca3bc..eff7f5d 100644 --- a/src/services/web3.service.ts +++ b/src/services/web3.service.ts @@ -1,5 +1,9 @@ import { ethers, JsonRpcProvider, Wallet } from "ethers"; -import { TickerPriceStorageAbi__factory, TickerUSDFeedRegistryAbi__factory } from "../contracts"; +import { + ChainlinkAggregatorV3Abi__factory, + TickerPriceStorageAbi__factory, + TickerUSDFeedRegistryAbi__factory +} from "../contracts"; import { CONFIG } from "../config"; @@ -23,6 +27,10 @@ class Web3Service { public getTickerPriceStorageContract(address: string, isMutatingState?: boolean) { return TickerPriceStorageAbi__factory.connect(address, this._getRunner(!!isMutatingState)); } + + public getChainlinkPriceFeedContract(address: string) { + return ChainlinkAggregatorV3Abi__factory.connect(address, this._getRunner(false)); + } } // We'll create a singleton for now while it's single network client