diff --git a/.env.example b/.env.example index ecc266af..dbee720e 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,13 @@ #ORDERBOOK_DATABASE_USERNAME= #ORDERBOOK_DATABASE_PASSWORD= +# Analytics database (used in Yield aka Vampire attack) +#COW_ANALYTICS_DATABASE_NAME= +#COW_ANALYTICS_DATABASE_HOST= +#COW_ANALYTICS_DATABASE_PORT= +#COW_ANALYTICS_DATABASE_USERNAME= +#COW_ANALYTICS_DATABASE_PASSWORD= + # CMS #CMS_API_KEY= #CMS_BASE_URL=https://cms.cow.fi diff --git a/apps/api/src/app/data/poolInfo.ts b/apps/api/src/app/data/poolInfo.ts new file mode 100644 index 00000000..d5860b21 --- /dev/null +++ b/apps/api/src/app/data/poolInfo.ts @@ -0,0 +1,32 @@ +import { + Column, + Entity, + PrimaryColumn +} from 'typeorm'; +import { bufferToString, stringToBuffer } from '@cowprotocol/shared'; + +@Entity({ name: 'cow_amm_competitor_info', schema: 'public' }) +export class PoolInfo { + @PrimaryColumn('bytea', { + transformer: { from: bufferToString, to: stringToBuffer }, + }) + contract_address: string; + + @Column('int') + chain_id: number; + + @Column('varchar') + project: string; + + @Column('double precision') + apr: number; + + @Column('double precision') + fee: number; + + @Column('double precision') + tvl: number; + + @Column('double precision') + volume: number; +} diff --git a/apps/api/src/app/plugins/env.ts b/apps/api/src/app/plugins/env.ts index 1afb8b27..8ada4c91 100644 --- a/apps/api/src/app/plugins/env.ts +++ b/apps/api/src/app/plugins/env.ts @@ -41,6 +41,23 @@ const schema = { MORALIS_API_KEY: { type: 'string', }, + + // CoW Analytics DB + COW_ANALYTICS_DATABASE_NAME: { + type: 'string', + }, + COW_ANALYTICS_DATABASE_HOST: { + type: 'string', + }, + COW_ANALYTICS_DATABASE_PORT: { + type: 'number', + }, + COW_ANALYTICS_DATABASE_USERNAME: { + type: 'string', + }, + COW_ANALYTICS_DATABASE_PASSWORD: { + type: 'string', + }, }, }; diff --git a/apps/api/src/app/plugins/orm.ts b/apps/api/src/app/plugins/orm.ts new file mode 100644 index 00000000..dce974b5 --- /dev/null +++ b/apps/api/src/app/plugins/orm.ts @@ -0,0 +1,42 @@ +import 'reflect-metadata'; +import { FastifyInstance } from 'fastify'; +import typeORMPlugin from 'typeorm-fastify-plugin'; +import fp from 'fastify-plugin'; +import { PoolInfo } from '../data/poolInfo'; + +export default fp(async function (fastify: FastifyInstance) { + const dbParams = { + host: fastify.config.COW_ANALYTICS_DATABASE_HOST, + port: Number(fastify.config.COW_ANALYTICS_DATABASE_PORT), + database: fastify.config.COW_ANALYTICS_DATABASE_NAME, + username: fastify.config.COW_ANALYTICS_DATABASE_USERNAME, + password: fastify.config.COW_ANALYTICS_DATABASE_PASSWORD, + } + + const dbParamsAreInvalid = Object.values(dbParams).some((v) => Number.isNaN(v) || v === undefined); + + if (dbParamsAreInvalid) { + console.error('Invalid CoW Analytics database parameters, please check COW_ANALYTICS_* env vars'); + return + } + + fastify.register(typeORMPlugin, { + ...dbParams, + type: 'postgres', + entities: [PoolInfo], + ssl: true, + extra: { + ssl: { + rejectUnauthorized: false + } + } + }); + + fastify.ready((err) => { + if (err) { + throw err; + } + + fastify.orm.runMigrations({ transaction: 'all' }); + }); +}); diff --git a/apps/api/src/app/routes/__chainId/yield/const.ts b/apps/api/src/app/routes/__chainId/yield/const.ts new file mode 100644 index 00000000..37ad1b4a --- /dev/null +++ b/apps/api/src/app/routes/__chainId/yield/const.ts @@ -0,0 +1,4 @@ +import ms from 'ms'; + +export const POOLS_RESULT_LIMIT = 500 +export const POOLS_QUERY_CACHE = ms('12h') \ No newline at end of file diff --git a/apps/api/src/app/routes/__chainId/yield/getPoolsAverageApr.ts b/apps/api/src/app/routes/__chainId/yield/getPoolsAverageApr.ts new file mode 100644 index 00000000..046a3796 --- /dev/null +++ b/apps/api/src/app/routes/__chainId/yield/getPoolsAverageApr.ts @@ -0,0 +1,65 @@ +import { FastifyPluginAsync } from 'fastify'; +import { FromSchema } from 'json-schema-to-ts'; +import { PoolInfo } from '../../../data/poolInfo'; +import { + errorSchema, + paramsSchema, + poolsAverageAprBodySchema +} from './schemas'; +import { trimDoubleQuotes } from './utils'; +import { CACHE_CONTROL_HEADER, getCacheControlHeaderValue } from '../../../../utils/cache'; + +type RouteSchema = FromSchema; +type SuccessSchema = FromSchema; +type ErrorSchema = FromSchema; + +interface PoolInfoResult { + project: string; + average_apr: number; +} + +const CACHE_SECONDS = 21600; // 6 hours + +const root: FastifyPluginAsync = async (fastify): Promise => { + fastify.get<{ + Params: RouteSchema; + Reply: SuccessSchema | ErrorSchema; + }>( + '/pools-average-apr', + { + schema: { + params: paramsSchema, + response: { + '2XX': poolsAverageAprBodySchema, + '400': errorSchema, + }, + }, + }, + async function (request, reply) { + const { chainId } = request.params; + + const poolInfoRepository = fastify.orm.getRepository(PoolInfo); + + const result = await poolInfoRepository.query(` + SELECT project, + AVG(apr) AS average_apr + FROM cow_amm_competitor_info + WHERE chain_id = ${chainId} + GROUP BY project; + `) + + const averageApr = result.reduce((acc: Record, val: PoolInfoResult) => { + const projectName = trimDoubleQuotes(val.project) + + acc[projectName] = +val.average_apr.toFixed(6) + + return acc + }, {}) + + reply.header(CACHE_CONTROL_HEADER, getCacheControlHeaderValue(CACHE_SECONDS)); + reply.status(200).send(averageApr); + } + ); +}; + +export default root; diff --git a/apps/api/src/app/routes/__chainId/yield/getPoolsInfo.ts b/apps/api/src/app/routes/__chainId/yield/getPoolsInfo.ts new file mode 100644 index 00000000..67982163 --- /dev/null +++ b/apps/api/src/app/routes/__chainId/yield/getPoolsInfo.ts @@ -0,0 +1,58 @@ +import { FastifyPluginAsync } from 'fastify'; +import { FromSchema } from 'json-schema-to-ts'; +import { PoolInfo } from '../../../data/poolInfo'; +import { In } from 'typeorm'; +import { poolsInfoBodySchema, errorSchema, paramsSchema, poolsInfoSuccessSchema } from './schemas'; +import { POOLS_QUERY_CACHE, POOLS_RESULT_LIMIT } from './const'; +import { trimDoubleQuotes } from './utils'; + +type RouteSchema = FromSchema; +type SuccessSchema = FromSchema; +type ErrorSchema = FromSchema; +type BodySchema = FromSchema; + +const root: FastifyPluginAsync = async (fastify): Promise => { + fastify.post<{ + Params: RouteSchema; + Reply: SuccessSchema | ErrorSchema; + Body: BodySchema; + }>( + '/pools', + { + schema: { + params: paramsSchema, + response: { + '2XX': poolsInfoSuccessSchema, + '400': errorSchema, + }, + body: poolsInfoBodySchema + }, + }, + async function (request, reply) { + const { chainId } = request.params; + const poolsAddresses = request.body; + + const poolInfoRepository = fastify.orm.getRepository(PoolInfo); + + const results = await poolInfoRepository.find({ + take: POOLS_RESULT_LIMIT, + where: { + ...(poolsAddresses.length > 0 ? { contract_address: In(poolsAddresses) } : null), + chain_id: chainId + }, + cache: POOLS_QUERY_CACHE + }) + + const mappedResults = results.map(res => { + return { + ...res, + project: trimDoubleQuotes(res.project) + } + }) + + reply.status(200).send(mappedResults); + } + ); +}; + +export default root; diff --git a/apps/api/src/app/routes/__chainId/yield/schemas.ts b/apps/api/src/app/routes/__chainId/yield/schemas.ts new file mode 100644 index 00000000..cd6291e7 --- /dev/null +++ b/apps/api/src/app/routes/__chainId/yield/schemas.ts @@ -0,0 +1,92 @@ +import { AddressSchema, ChainIdSchema } from '../../../schemas'; +import { JSONSchema } from 'json-schema-to-ts'; +import { POOLS_RESULT_LIMIT } from './const'; + +export const paramsSchema = { + type: 'object', + required: ['chainId'], + additionalProperties: false, + properties: { + chainId: ChainIdSchema, + }, +} as const satisfies JSONSchema; + +export const poolsInfoSuccessSchema = { + type: 'array', + items: { + type: 'object', + required: [ + 'contract_address', + 'chain_id', + 'project', + 'apr', + 'fee', + 'tvl', + 'volume' + ], + additionalProperties: false, + properties: { + contract_address: { + title: 'Pool address', + type: 'string', + pattern: AddressSchema.pattern + }, + chain_id: ChainIdSchema, + project: { + title: 'Liquidity provider', + type: 'string', + }, + apr: { + title: 'APR', + description: 'Annual Percentage Rate', + type: 'number', + }, + fee: { + title: 'Fee tier', + description: 'Pool fee percent', + type: 'number', + }, + tvl: { + title: 'TVL', + description: 'Total value locked (in USD)', + type: 'number', + }, + volume: { + title: 'Volume 24h', + description: 'Trading volume in the last 24 hours (in USD)', + type: 'number', + }, + }, + }, +} as const satisfies JSONSchema; + +export const poolsInfoBodySchema = { + type: 'array', + items: { + title: 'Pool address', + description: 'Blockchain address of the pool', + type: 'string', + pattern: AddressSchema.pattern, + }, + maxItems: POOLS_RESULT_LIMIT +} as const satisfies JSONSchema; + +export const poolsAverageAprBodySchema = { + type: 'object', + title: 'Liquidity provider - apr', + additionalProperties: true +} as const satisfies JSONSchema; + + +export const errorSchema = { + type: 'object', + required: ['message'], + additionalProperties: false, + properties: { + message: { + title: 'Message', + description: 'Message describing the error.', + type: 'string', + }, + }, +} as const satisfies JSONSchema; \ No newline at end of file diff --git a/apps/api/src/app/routes/__chainId/yield/types.ts b/apps/api/src/app/routes/__chainId/yield/types.ts new file mode 100644 index 00000000..4e22d63f --- /dev/null +++ b/apps/api/src/app/routes/__chainId/yield/types.ts @@ -0,0 +1,6 @@ +export interface PoolInfo { + apy: number + tvl: number + feeTier: number + volume24h: number +} \ No newline at end of file diff --git a/apps/api/src/app/routes/__chainId/yield/utils.ts b/apps/api/src/app/routes/__chainId/yield/utils.ts new file mode 100644 index 00000000..7b039229 --- /dev/null +++ b/apps/api/src/app/routes/__chainId/yield/utils.ts @@ -0,0 +1,11 @@ +export function trimDoubleQuotes(value: string): string { + if (value[0] === '"') { + return trimDoubleQuotes(value.slice(1)) + } + + if (value[value.length - 1] === '"') { + return trimDoubleQuotes(value.slice(0, -1)) + } + + return value +} \ No newline at end of file diff --git a/apps/api/src/datasource.config.ts b/apps/api/src/datasource.config.ts new file mode 100644 index 00000000..4d38ba6a --- /dev/null +++ b/apps/api/src/datasource.config.ts @@ -0,0 +1,16 @@ +import * as dotenv from 'dotenv'; +import { DataSource } from 'typeorm'; + +dotenv.config(); + +export const cowAnalyticsDb = new DataSource({ + type: 'postgres', + host: process.env.COW_ANALYTICS_DATABASE_HOST, + port: Number(process.env.COW_ANALYTICS_DATABASE_PORT), + username: process.env.COW_ANALYTICS_DATABASE_USERNAME, + password: process.env.COW_ANALYTICS_DATABASE_PASSWORD, + database: process.env.COW_ANALYTICS_DATABASE_NAME, + entities: ['src/app/data/*.ts'], +}); + +cowAnalyticsDb.initialize(); diff --git a/apps/api/tsconfig.app.json b/apps/api/tsconfig.app.json index f5e2e085..1314ca3b 100644 --- a/apps/api/tsconfig.app.json +++ b/apps/api/tsconfig.app.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "module": "commonjs", - "types": ["node"] + "types": ["node"], }, "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], "include": ["src/**/*.ts"] diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index c1e2dd4e..bf773b83 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -11,6 +11,7 @@ } ], "compilerOptions": { + "strictPropertyInitialization": false, "esModuleInterop": true } } diff --git a/apps/twap/src/app/orderbook/order.ts b/apps/twap/src/app/orderbook/order.ts index cac561d4..6fb6cf7f 100644 --- a/apps/twap/src/app/orderbook/order.ts +++ b/apps/twap/src/app/orderbook/order.ts @@ -4,7 +4,7 @@ import { bufferToString, stringToBigInt, stringToBuffer, -} from '../utils/transformers'; +} from '@cowprotocol/shared'; @Entity({ name: 'orders' }) export class Order { diff --git a/apps/twap/src/app/orderbook/settlement.ts b/apps/twap/src/app/orderbook/settlement.ts index cc7d2c9b..f2d117ff 100644 --- a/apps/twap/src/app/orderbook/settlement.ts +++ b/apps/twap/src/app/orderbook/settlement.ts @@ -4,7 +4,7 @@ import { bufferToString, stringToBigInt, stringToBuffer, -} from '../utils/transformers'; +} from '@cowprotocol/shared'; @Entity({ name: 'settlements' }) export class Settlement { diff --git a/apps/twap/src/app/orderbook/trade.ts b/apps/twap/src/app/orderbook/trade.ts index 25f7e344..849b8c30 100644 --- a/apps/twap/src/app/orderbook/trade.ts +++ b/apps/twap/src/app/orderbook/trade.ts @@ -4,7 +4,7 @@ import { bufferToString, stringToBigInt, stringToBuffer, -} from '../utils/transformers'; +} from '@cowprotocol/shared'; @Entity({ name: 'trades' }) export class Trade { diff --git a/libs/shared/src/index.ts b/libs/shared/src/index.ts index 7918be87..76a41556 100644 --- a/libs/shared/src/index.ts +++ b/libs/shared/src/index.ts @@ -1,3 +1,4 @@ export * from './types'; export * from './const'; export * from './utils'; +export * from './transformers'; diff --git a/apps/twap/src/app/utils/transformers.ts b/libs/shared/src/transformers.ts similarity index 100% rename from apps/twap/src/app/utils/transformers.ts rename to libs/shared/src/transformers.ts