diff --git a/.env.devnet b/.env.devnet index c562a53..bbee777 100644 --- a/.env.devnet +++ b/.env.devnet @@ -2,6 +2,7 @@ NETWORK=mainnet API_URL=https://api.multiversx.com DATA_API_CEX_URL=https://data-api.multiversx.com/v1/quotes/cex DATA_API_XEXCHANGE_URL=https://data-api.multiversx.com/v1/quotes/xexchange +DATA_API_HATOM_URL=https://data-api.multiversx.com/v1/quotes/hatom # DUNE_API_URL=http://localhost:3001/api/v1/table DUNE_API_URL=https://api.dune.com/api/v1/table DUNE_NAMESPACE=stefanmvx diff --git a/.env.mainnet b/.env.mainnet index aacbf4d..59b77d9 100644 --- a/.env.mainnet +++ b/.env.mainnet @@ -2,6 +2,7 @@ NETWORK=mainnet API_URL=https://api.multiversx.com DATA_API_CEX_URL=https://data-api.multiversx.com/v1/quotes/cex DATA_API_XEXCHANGE_URL=https://data-api.multiversx.com/v1/quotes/xexchange +DATA_API_HATOM_URL=https://data-api.multiversx.com/v1/quotes/hatom DUNE_API_URL=http://localhost:3001/api/v1/table # DUNE_API_URL=https://api.dune.com/api/v1/table DUNE_NAMESPACE=stefanmvx diff --git a/.env.testnet b/.env.testnet index c562a53..bbee777 100644 --- a/.env.testnet +++ b/.env.testnet @@ -2,6 +2,7 @@ NETWORK=mainnet API_URL=https://api.multiversx.com DATA_API_CEX_URL=https://data-api.multiversx.com/v1/quotes/cex DATA_API_XEXCHANGE_URL=https://data-api.multiversx.com/v1/quotes/xexchange +DATA_API_HATOM_URL=https://data-api.multiversx.com/v1/quotes/hatom # DUNE_API_URL=http://localhost:3001/api/v1/table DUNE_API_URL=https://api.dune.com/api/v1/table DUNE_NAMESPACE=stefanmvx diff --git a/.multiversx/config/config.yaml b/.multiversx/config/config.yaml index fd37903..e06a5da 100644 --- a/.multiversx/config/config.yaml +++ b/.multiversx/config/config.yaml @@ -14,13 +14,14 @@ libs: api: ${API_URL} dataApiCex: ${DATA_API_CEX_URL} dataApiXexchange: ${DATA_API_XEXCHANGE_URL} + dataApiHatom: ${DATA_API_HATOM_URL} duneApi: ${DUNE_API_URL} database: host: 'localhost' port: 27017 # username: 'root' # password: 'root' - name: 'example' + name: 'duneDB' tlsAllowInvalidCertificates: true redis: host: '127.0.0.1' diff --git a/apps/api/src/config/app-config.service.ts b/apps/api/src/config/app-config.service.ts index 58d59cd..ec4e5e9 100644 --- a/apps/api/src/config/app-config.service.ts +++ b/apps/api/src/config/app-config.service.ts @@ -25,6 +25,10 @@ export class AppConfigService { return configuration().libs.common.urls.dataApiXexchange ?? ""; } + getDataApiHatomUrl(): string { + return configuration().libs.common.urls.dataApiHatom ?? ""; + } + getDuneApiUrl(): string { return configuration().libs.common.urls.duneApi ?? ""; } diff --git a/apps/api/src/endpoints/events/events.controller.ts b/apps/api/src/endpoints/events/events.controller.ts index 48c86aa..7d4d1fd 100644 --- a/apps/api/src/endpoints/events/events.controller.ts +++ b/apps/api/src/endpoints/events/events.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Param, Post } from "@nestjs/common"; import { ApiTags } from "@nestjs/swagger"; import { EventLog } from "./entities/event.log"; -import { HatomBorrowEventsService, LiquidityEventsService } from "@libs/services/events"; +import { HatomBorrowEventsService, HatomLiquidationService, LiquidityEventsService } from "@libs/services/events"; @Controller('/events') @ApiTags('events') @@ -9,6 +9,7 @@ export class EventsController { constructor( private readonly liquidityService: LiquidityEventsService, private readonly hatomBorrowService: HatomBorrowEventsService, + private readonly hatomLiquidationService: HatomLiquidationService, ) { } @Post("/liquidity-webhook") @@ -18,7 +19,7 @@ export class EventsController { await this.liquidityService.liquidityWebhook(body); } - @Post("/hatom-webhook/:borrowed_token") + @Post("/hatom-borrow-webhook/:borrowed_token") async hatomBorrowWebhook( @Body() body: EventLog[], @Param('borrowed_token') borrowedToken: string, @@ -26,4 +27,11 @@ export class EventsController { await this.hatomBorrowService.hatomBorrowWebhook(body, borrowedToken); } + @Post("/hatom-liquidation-webhook") + async hatomLiquidationWebhook( + @Body() body: EventLog[], + ): Promise { + await this.hatomLiquidationService.hatomLiquidationWebhook(body); + } + } diff --git a/config/config.yaml b/config/config.yaml index fd37903..e06a5da 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -14,13 +14,14 @@ libs: api: ${API_URL} dataApiCex: ${DATA_API_CEX_URL} dataApiXexchange: ${DATA_API_XEXCHANGE_URL} + dataApiHatom: ${DATA_API_HATOM_URL} duneApi: ${DUNE_API_URL} database: host: 'localhost' port: 27017 # username: 'root' # password: 'root' - name: 'example' + name: 'duneDB' tlsAllowInvalidCertificates: true redis: host: '127.0.0.1' diff --git a/config/schema.yaml b/config/schema.yaml index 23820ff..4b45872 100644 --- a/config/schema.yaml +++ b/config/schema.yaml @@ -17,6 +17,7 @@ libs: api: string dataApiCex: string dataApiXexchange: string + dataApiHatom: string duneApi: string database: host: string diff --git a/libs/common/src/entities/config.d.ts b/libs/common/src/entities/config.d.ts index 5c61796..53bb019 100644 --- a/libs/common/src/entities/config.d.ts +++ b/libs/common/src/entities/config.d.ts @@ -20,6 +20,7 @@ export interface Config { api: string; dataApiCex: string; dataApiXexchange: string; + dataApiHatom: string; duneApi: string; }; database: { diff --git a/libs/services/src/data/data.service.ts b/libs/services/src/data/data.service.ts index ca45c4f..c3bee24 100644 --- a/libs/services/src/data/data.service.ts +++ b/libs/services/src/data/data.service.ts @@ -23,16 +23,28 @@ export class DataService { private readonly appConfigService: AppConfigService, ) { } - async getTokenPrice(tokenId: string, date: moment.Moment): Promise { + async getTokenPrice(tokenId: string, date: moment.Moment, market?: string): Promise { return await this.cachingService.getOrSet( CacheInfo.TokenPrice(tokenId, date).key, - async () => await this.getTokenPriceRaw(tokenId, date), + async () => await this.getTokenPriceRaw(tokenId, date, market), CacheInfo.TokenPrice(tokenId, date).ttl ); } - async getTokenPriceRaw(tokenId: string, date: moment.Moment): Promise { + async getTokenPriceRaw(tokenId: string, date: moment.Moment, market?: string): Promise { try { + if (market) { + switch (market) { + case 'hatom': + return (await axios.get(`${this.appConfigService.getDataApiHatomUrl()}/${tokenId}?date=${date.format('YYYY-MM-DD')}`)).data.price; + case 'xexchange': + return (await axios.get(`${this.appConfigService.getDataApiXexchangeUrl()}/${tokenId}?date=${date.format('YYYY-MM-DD')}`)).data.price; + case 'cex': + return (await axios.get(`${this.appConfigService.getDataApiCexUrl()}/${tokenId}?date=${date.format('YYYY-MM-DD')}`)).data.price; + default: + throw Error('Invalid market !'); + } + } if (tokenId.startsWith('USD')) { return (await axios.get(`${this.appConfigService.getDataApiCexUrl()}/${tokenId}?date=${date.format('YYYY-MM-DD')}`)).data.price; } diff --git a/libs/services/src/events/hatom.borrow.events.service.ts b/libs/services/src/events/hatom.borrow.events.service.ts index 55a80b5..e2dc7a3 100644 --- a/libs/services/src/events/hatom.borrow.events.service.ts +++ b/libs/services/src/events/hatom.borrow.events.service.ts @@ -1,14 +1,13 @@ import { Injectable } from "@nestjs/common"; import { EventLog } from "apps/api/src/endpoints/events/entities"; -import { Address } from "@multiversx/sdk-core"; import BigNumber from "bignumber.js"; import { CsvRecordsService } from "../records"; import moment from "moment"; import { DataService } from "../data"; import { TableSchema } from "apps/dune-simulator/src/endpoints/dune-simulator/entities"; -import { joinCsvAttributes } from "libs/services/utils"; +import { borrowEvent, decodeTopics, HatomEvent, joinCsvAttributes } from "libs/services/utils"; -interface BorrowEvent { +interface BorrowEvent extends HatomEvent { eventName: string; borrowerAddress: string; amount: BigNumber; @@ -35,12 +34,11 @@ export class HatomBorrowEventsService { ) { } public async hatomBorrowWebhook(eventsLog: EventLog[], borrowedToken: string): Promise { - for (const eventLog of eventsLog) { - const borrowEventInHex = '626f72726f775f6576656e74'; // 'borrow_event' - - if (eventLog.identifier === "borrow" && eventLog.topics[0] === borrowEventInHex) { - const currentEvent = this.decodeTopics(eventLog); + if (eventLog.identifier === "borrow" && eventLog.topics[0] === borrowEvent) { + const properties: string[] = ["eventName", "borrowerAddress", "amount", "newAccountBorrow", "newTotalBorrows", "newBorrowerIndex"]; + const types: string[] = ["String", "Address", "BigNumber", "BigNumber", "BigNumber", "BigNumber"]; + const currentEvent: BorrowEvent = decodeTopics(properties, eventLog.topics, types) as BorrowEvent; const eventDate = moment.unix(eventLog.timestamp); const [borrowedAmountInEGLD, borrowedAmountInUSD] = await this.convertBorrowedAmount(currentEvent, borrowedToken, eventDate); @@ -66,19 +64,6 @@ export class HatomBorrowEventsService { } } - decodeTopics(eventLog: EventLog): BorrowEvent { - const currentEvent: BorrowEvent = { - eventName: Buffer.from(eventLog.topics[0], 'hex').toString(), - borrowerAddress: Address.newFromHex(Buffer.from(eventLog.topics[1], 'hex').toString('hex')).toBech32(), - amount: BigNumber(Buffer.from(eventLog.topics[2], 'hex').toString('hex'), 16), - newAccountBorrow: BigNumber(Buffer.from(eventLog.topics[3], 'hex').toString('hex'), 16), - newTotalBorrows: BigNumber(Buffer.from(eventLog.topics[4], 'hex').toString('hex'), 16), - newBorrowerIndex: BigNumber(Buffer.from(eventLog.topics[5], 'hex').toString('hex'), 16), - }; - - return currentEvent; - } - async convertBorrowedAmount(currentEvent: BorrowEvent, borrowedToken: string, date: moment.Moment): Promise<[BigNumber, BigNumber]> { let borrowedAmountInEGLD, borrowedAmountInUSD; @@ -93,5 +78,3 @@ export class HatomBorrowEventsService { return [borrowedAmountInEGLD, borrowedAmountInUSD]; } } - - diff --git a/libs/services/src/events/hatom.liquidation.service.ts b/libs/services/src/events/hatom.liquidation.service.ts new file mode 100644 index 0000000..8bed7ff --- /dev/null +++ b/libs/services/src/events/hatom.liquidation.service.ts @@ -0,0 +1,92 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { TableSchema } from "apps/dune-simulator/src/endpoints/dune-simulator/entities"; +import { EventLog } from "apps/api/src/endpoints/events/entities"; +import { CsvRecordsService } from "../records"; +import { liquidationBorrowEvent, decodeTopics, getTokenIdByMoneyMarket, HatomEvent, joinCsvAttributes } from "libs/services/utils"; +import BigNumber from "bignumber.js"; +import moment from "moment"; +import { DataService } from "../data"; + +interface LiquidationEvent extends HatomEvent { + liquidator: string, + borrower: string, + amount: BigNumber, + collateral_mma: string, + tokens: BigNumber, +} + +@Injectable() +export class HatomLiquidationService { + private readonly headers: TableSchema[] = [ + { name: "timestamp", type: "varchar" }, + { name: 'liquidator', type: 'varchar' }, + { name: 'account_liquidated', type: 'varchar' }, + { name: 'token', type: 'varchar' }, + { name: 'amount', type: 'double' }, + { name: 'amount_in_egld', type: 'double' }, + { name: 'amount_in_usd', type: 'double' }, + { name: 'total_liquidated_in_egld', type: 'double' }, + { name: 'total_liquidated_in_usd', type: 'double' }, + ]; + + constructor( + private readonly csvRecordsService: CsvRecordsService, + private readonly dataService: DataService, + ) {} + + public async hatomLiquidationWebhook(eventsLog: EventLog[]): Promise { + const liquidationBorrowTopicsLength = 6; + const totalLiquidatedInEgld = new BigNumber(0); + const totalLiquidatedInUsd = new BigNumber(0); + + for (const eventLog of eventsLog) { + console.log(eventLog); + if (eventLog.identifier === "liquidateBorrow" && eventLog.topics.length === liquidationBorrowTopicsLength && eventLog.topics[0] === liquidationBorrowEvent) { + const properties: string[] = ["liquidator", "borrower", "amount", "collateral_mma", "tokens"]; + const types: string[] = ["Address", "Address", "BigNumber", "Address", "BigNumber"]; + const currentEvent: LiquidationEvent = decodeTopics(properties, eventLog.topics.slice(1), types) as LiquidationEvent; + const eventDate = moment.unix(eventLog.timestamp); + + const tokenId: string | undefined = getTokenIdByMoneyMarket(currentEvent.collateral_mma); + + if (tokenId === undefined) { + Logger.warn(`Token ID not found for collateral MMA: ${currentEvent.collateral_mma}`); + continue; + } + + const tokenPrecision = await this.dataService.getTokenPrecision(tokenId); + const [liquidatedAmountInEGLD, liquidatedAmountInUSD] = await this.convertTokenValue(currentEvent.tokens, tokenId, eventDate); + totalLiquidatedInEgld.plus(liquidatedAmountInEGLD); + totalLiquidatedInUsd.plus(liquidatedAmountInUSD); + + await this.csvRecordsService.pushRecord( + "hatom_liquidation_events", + [ + joinCsvAttributes( + eventDate.format('YYYY-MM-DD HH:mm:ss.SSS'), + currentEvent.liquidator, + currentEvent.borrower, + tokenId, + currentEvent.tokens.shiftedBy(-tokenPrecision).decimalPlaces(4), + liquidatedAmountInEGLD.shiftedBy(-tokenPrecision).decimalPlaces(4), + liquidatedAmountInUSD.shiftedBy(-tokenPrecision).decimalPlaces(4), + totalLiquidatedInEgld.shiftedBy(-tokenPrecision).decimalPlaces(4), + totalLiquidatedInUsd.shiftedBy(-tokenPrecision).decimalPlaces(4), + ), + ], + this.headers + ); + } + } + } + + async convertTokenValue(amount: BigNumber, tokenID: string, date: moment.Moment): Promise<[BigNumber, BigNumber]> { + const egldPrice = await this.dataService.getTokenPrice('WEGLD-bd4d79', date); + const tokenPrice = await this.dataService.getTokenPrice(tokenID, date, 'hatom'); + + const valueInUsd = amount.multipliedBy(tokenPrice); + const valueInEgld = valueInUsd.dividedBy(egldPrice); + + return [valueInEgld, valueInUsd]; + } +} diff --git a/libs/services/src/events/index.ts b/libs/services/src/events/index.ts index 7437fe5..9962c31 100644 --- a/libs/services/src/events/index.ts +++ b/libs/services/src/events/index.ts @@ -1,2 +1,3 @@ export * from './liquidity.events.service'; export * from './hatom.borrow.events.service'; +export * from './hatom.liquidation.service'; diff --git a/libs/services/src/events/liquidity.events.service.ts b/libs/services/src/events/liquidity.events.service.ts index de6fd84..ba0c363 100644 --- a/libs/services/src/events/liquidity.events.service.ts +++ b/libs/services/src/events/liquidity.events.service.ts @@ -27,12 +27,6 @@ export class LiquidityEventsService { let currentEvent: AddLiquidityEvent | RemoveLiquidityEvent; for (const eventLog of eventsLog) { - // We need to parse an event only when we receive data from events-log-service - - // eventLog.topics = eventLog.topics.map((topic) => Buffer.from(topic, 'hex').toString('base64')); - // eventLog.data = Buffer.from(eventLog.data, 'hex').toString('base64'); - // eventLog.additionalData = eventLog.additionalData.map((data) => Buffer.from(data, 'hex').toString('base64')); - switch (eventLog.identifier) { case "addLiquidity": currentEvent = new AddLiquidityEvent(eventLog); @@ -54,7 +48,12 @@ export class LiquidityEventsService { for (let i = 0; i < diff; i++) { this.lastDate[csvFileName].add(1, 'hour').startOf('hour'); - const liquidity = await this.computeLiquidty(this.lastFirstTokenReserves[csvFileName], this.lastSecondTokenReserves[csvFileName], firstTokenId, secondTokenId, this.lastDate[csvFileName]); + const liquidity = await this.computeLiquidty( + this.lastFirstTokenReserves[csvFileName], + this.lastSecondTokenReserves[csvFileName], + firstTokenId, + secondTokenId, + this.lastDate[csvFileName]); await this.csvRecordsService.pushRecord( csvFileName, [ diff --git a/libs/services/src/services.module.ts b/libs/services/src/services.module.ts index 758fb50..62cdbd2 100644 --- a/libs/services/src/services.module.ts +++ b/libs/services/src/services.module.ts @@ -1,7 +1,7 @@ import { Global, Module } from '@nestjs/common'; import { DatabaseModule } from '@libs/database'; import { DynamicModuleUtils } from '@libs/common'; -import { HatomBorrowEventsService, LiquidityEventsService } from './events'; +import { HatomBorrowEventsService, HatomLiquidationService, LiquidityEventsService } from './events'; import { DataService } from './data'; import { DuneSenderService } from './dune-sender'; import { CsvRecordsService } from './records'; @@ -19,6 +19,7 @@ import { CsvRecordsService } from './records'; DuneSenderService, CsvRecordsService, HatomBorrowEventsService, + HatomLiquidationService, ], exports: [ LiquidityEventsService, @@ -26,6 +27,7 @@ import { CsvRecordsService } from './records'; DuneSenderService, CsvRecordsService, HatomBorrowEventsService, + HatomLiquidationService, ], }) export class ServicesModule { } diff --git a/libs/services/utils/hatom_events/hatom-utils.ts b/libs/services/utils/hatom_events/hatom-utils.ts new file mode 100644 index 0000000..2cba9f1 --- /dev/null +++ b/libs/services/utils/hatom_events/hatom-utils.ts @@ -0,0 +1,59 @@ +import { Address } from "@multiversx/sdk-core/out"; +import BigNumber from "bignumber.js"; + +export interface HatomEvent { + [key: string]: any; +} + +export function decodeTopics(properties: string[], topics: string[], types: string[]): HatomEvent { + if (properties.length !== topics.length) { + throw new Error("Invalid properties length"); + } + + const result: HatomEvent = {}; + for (let i = 0; i < properties.length; i++) { + result[properties[i]] = decodeSingleTopic(topics[i], types[i]); + } + return result; +} + +export function decodeSingleTopic(topic: string, out: string = "String"): any { + if (out === "String") { + return Buffer.from(topic, 'hex').toString(); + } + if (out === "Address") { + return Address.fromHex(topic).toBech32(); + } + if (out === "BigNumber") { + return new BigNumber(topic, 16); + } +} + +export function getTokenIdByMoneyMarket(moneyMarket: string): string | undefined { + switch (moneyMarket) { + case 'erd1qqqqqqqqqqqqqpgqta0tv8d5pjzmwzshrtw62n4nww9kxtl278ssspxpxu': + return 'HUTK-4fa4b2'; + case 'erd1qqqqqqqqqqqqqpgqkrgsvct7hfx7ru30mfzk3uy6pxzxn6jj78ss84aldu': + return 'HUSDC-d80042'; + case 'erd1qqqqqqqqqqqqqpgqvxn0cl35r74tlw2a8d794v795jrzfxyf78sstg8pjr': + return 'HUSDT-6f0914'; + case 'erd1qqqqqqqqqqqqqpgqxmn4jlazsjp6gnec95423egatwcdfcjm78ss5q550k': + return 'HSEGLD-c13a4e'; + case 'erd1qqqqqqqqqqqqqpgq35qkf34a8svu4r2zmfzuztmeltqclapv78ss5jleq3': + return 'HEGLD-d61095'; + case 'erd1qqqqqqqqqqqqqpgqz9pvuz22qvqxfqpk6r3rluj0u2can55c78ssgcqs00': + return 'HWTAO-2e9136'; + case 'erd1qqqqqqqqqqqqqpgqxerzmkr80xc0qwa8vvm5ug9h8e2y7jgsqk2svevje0': + return 'HHTM-e03ba5'; + case 'erd1qqqqqqqqqqqqqpgq8h8upp38fe9p4ny9ecvsett0usu2ep7978ssypgmrs': + return 'HWETH-b3d17e'; + case 'erd1qqqqqqqqqqqqqpgqg47t8v5nwzvdxgf6g5jkxleuplu8y4f678ssfcg5gy': + return 'HWBTC-49ca31'; + case 'erd1qqqqqqqqqqqqqpgqdvrqup8k9mxvhvnc7cnzkcs028u95s5378ssr9d72p': + return 'HBUSD-ac1fca'; + case 'erd1qqqqqqqqqqqqqpgq7sspywe6e2ehy7dn5dz00ved3aa450mv78ssllmln6': + return 'HSWTAO-6df80c'; + default: + return undefined; + } +} diff --git a/libs/services/utils/hatom_events/hex-constants.ts b/libs/services/utils/hatom_events/hex-constants.ts new file mode 100644 index 0000000..c5da519 --- /dev/null +++ b/libs/services/utils/hatom_events/hex-constants.ts @@ -0,0 +1,2 @@ +export const liquidationBorrowEvent = '6c69717569646174655f626f72726f775f6576656e74'; // 'liquidation_borrow_event' +export const borrowEvent = '626f72726f775f6576656e74'; // 'borrow_event' diff --git a/libs/services/utils/hatom_events/index.ts b/libs/services/utils/hatom_events/index.ts new file mode 100644 index 0000000..f1a6285 --- /dev/null +++ b/libs/services/utils/hatom_events/index.ts @@ -0,0 +1,2 @@ +export * from './hex-constants'; +export * from './hatom-utils'; diff --git a/libs/services/utils/index.ts b/libs/services/utils/index.ts index 04bca77..108a893 100644 --- a/libs/services/utils/index.ts +++ b/libs/services/utils/index.ts @@ -1 +1,2 @@ export * from './utils'; +export * from './hatom_events';