├── .gitignore ├── bun.lockb ├── src ├── logger │ ├── types.ts │ ├── pinoLogger.ts │ ├── index.ts │ └── discord.ts ├── index.ts ├── utils │ ├── errors.ts │ └── index.ts ├── api │ ├── coingecko │ │ ├── api.ts │ │ ├── schemas.ts │ │ └── index.ts │ └── thorchain │ │ ├── schemas.ts │ │ ├── api.ts │ │ └── index.ts ├── config │ ├── schema.ts │ └── index.ts ├── bullmq │ ├── queues.ts │ ├── dashboard.ts │ ├── scheduler.ts │ └── workers.ts ├── services │ └── prices.ts ├── database │ └── redis │ │ └── index.ts ├── notifications │ └── index.ts └── jobs │ ├── pool.ts │ ├── networkPrice.ts │ ├── prices.ts │ ├── thorchainBalance.ts │ └── coingeckoComparison.ts ├── .env.example ├── Dockerfile ├── tests ├── poolMonitoring.test.ts ├── priceMonitoring.test.ts ├── vaultBalance.test.ts ├── thorchainBalance.test.ts ├── networkPrice.test.ts ├── api │ ├── coingecko.test.ts │ └── thorchain.test.ts └── coingeckoComparison.test.ts ├── docker-compose.yaml ├── tsconfig.json ├── package.json └── .github └── workflows └── publish.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.test 3 | node_modules 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hey/THORGate/main/bun.lockb -------------------------------------------------------------------------------- /src/logger/types.ts: -------------------------------------------------------------------------------- 1 | export type LogType = "error" | "info"; 2 | 3 | export interface LogDetails { 4 | [key: string]: any; 5 | } 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | REDIS_PORT=6381 3 | DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... 4 | DISCORD_WEBHOOK_URL_JOB=https://discord.com/api/webhooks/... 5 | NODE_ENV=development 6 | # COINGECKO_API_KEY=... 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:latest 2 | 3 | ARG VERSION 4 | ENV APP_VERSION=$VERSION 5 | ENV PORT=${PORT:-3000} 6 | 7 | WORKDIR /app 8 | 9 | COPY . . 10 | 11 | RUN bun install 12 | 13 | EXPOSE $PORT 14 | 15 | CMD bun start 16 | -------------------------------------------------------------------------------- /tests/poolMonitoring.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "bun:test"; 2 | import { notify } from "../src/jobs/pool"; 3 | 4 | test("should send a notification for pool property change", async () => { 5 | await notify("ETH.ETH", "balance_rune", 4923721.87391, 5416094.0613, 10, 60); 6 | }); 7 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: redis:latest 4 | container_name: redis_server 5 | ports: 6 | - "6381:6379" 7 | command: redis-server --appendonly yes --maxmemory-policy noeviction 8 | volumes: 9 | - redis_data:/data 10 | 11 | volumes: 12 | redis_data: 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { runDashboardServer } from "./bullmq/dashboard"; 2 | import { schedule } from "./bullmq/scheduler"; 3 | 4 | console.log("Scheduling jobs..."); 5 | await schedule(); 6 | console.log("Jobs scheduled."); 7 | console.log("Running dashboard server..."); 8 | await runDashboardServer(); 9 | console.log("Dashboard server running."); 10 | -------------------------------------------------------------------------------- /tests/priceMonitoring.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "bun:test"; 2 | import { notifyPriceChange } from "../src/jobs/prices"; 3 | 4 | test("should send a notification for significant price change", async () => { 5 | await notifyPriceChange( 6 | "BTC.BTC", 7 | 59760.9381554339, 8 | 55760.1381554339, 9 | 6.69467401866, 10 | 30, 11 | ); 12 | }); 13 | -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | export class APIError extends Error { 2 | status: number; 3 | path: string; 4 | stackTrace: string | undefined; 5 | 6 | constructor(status: number, message: string, path: string) { 7 | super(message); 8 | this.status = status; 9 | this.path = path; 10 | this.stackTrace = new Error().stack; 11 | this.name = "APIError"; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/vaultBalance.test.ts: -------------------------------------------------------------------------------- 1 | // import { test } from "bun:test"; 2 | // import { notify } from "../src/jobs/vaultBalance"; 3 | 4 | // test("should send a notification for significant vault balance change", async () => { 5 | // await notify( 6 | // "BTC.BTC", 7 | // BigInt(83612425408), 8 | // BigInt(72501132651), 9 | // 13.2775119617, 10 | // 5, 11 | // BigInt(59760), 12 | // ); 13 | // }); 14 | -------------------------------------------------------------------------------- /src/logger/pinoLogger.ts: -------------------------------------------------------------------------------- 1 | import pino from "pino"; 2 | import { isProduction } from "../config"; 3 | 4 | // Configure pino logger 5 | const logger = pino({ 6 | level: isProduction ? "info" : "debug", 7 | transport: isProduction 8 | ? undefined 9 | : { 10 | target: "pino-pretty", 11 | options: { 12 | colorize: true, 13 | translateTime: "SYS:standard", 14 | ignore: "pid,hostname", 15 | }, 16 | }, 17 | }); 18 | 19 | export const structuredLog = (type: "error" | "info", info: object) => { 20 | logger[type](info); 21 | }; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "module": "esnext", 5 | "target": "esnext", 6 | "moduleResolution": "bundler", 7 | "moduleDetection": "force", 8 | "allowImportingTsExtensions": true, 9 | "noEmit": true, 10 | "composite": true, 11 | "strict": true, 12 | "downlevelIteration": true, 13 | "skipLibCheck": true, 14 | "allowSyntheticDefaultImports": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "allowJs": true, 17 | "types": [ 18 | "bun-types" // add Bun global 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/api/coingecko/api.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Config } from "../../config"; 3 | 4 | export const fetchFromAPI = async ( 5 | url: string, 6 | schema: z.Schema, 7 | ): Promise => { 8 | const headers: Record = {}; 9 | if (Config.coingecko.apiKey) { 10 | headers["x-cg-pro-api-key"] = Config.coingecko.apiKey; 11 | } 12 | 13 | const response = await fetch(url, { headers }); 14 | if (!response.ok) { 15 | throw new Error(`HTTP error! status: ${response.status}`); 16 | } 17 | 18 | const data = await response.json(); 19 | return schema.parse(data); 20 | }; 21 | -------------------------------------------------------------------------------- /src/api/coingecko/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | // Zod Schemas 4 | export const CoinGeckoPriceItemSchema = z.object({ 5 | usd: z.number(), 6 | }); 7 | 8 | export const CoinGeckoPriceSchema = z.record(CoinGeckoPriceItemSchema); 9 | 10 | export const CoinGeckoCoinItemSchema = z.object({ 11 | id: z.string(), 12 | symbol: z.string(), 13 | name: z.string(), 14 | }); 15 | 16 | export const CoinGeckoCoinSchema = z.array(CoinGeckoCoinItemSchema); 17 | 18 | // TypeScript Interfaces 19 | export type CoinGeckoPrice = z.infer; 20 | export type CoinGeckoCoin = z.infer; 21 | export type CoinGeckoCoinItem = z.infer; 22 | -------------------------------------------------------------------------------- /tests/thorchainBalance.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "bun:test"; 2 | import { notify } from "../src/jobs/thorchainBalance"; 3 | 4 | test("should send a notification for significant balance change", async () => { 5 | const denom = "avax/avax"; 6 | const address = "thor1g98cy3n9mmjrpn0sxmn63lztelera37n8n67c0"; 7 | const nickname = "Pool Module"; 8 | const amountBefore = 32029.95486666; 9 | const amountBeforeUSD = 765444.972592; 10 | const amountAfter = 27648.89764; 11 | const amountAfterUSD = 660747.409241; 12 | const percentageChange = 13.678; 13 | const minutesAgo = 5; 14 | 15 | await notify( 16 | denom, 17 | address, 18 | nickname, 19 | amountBefore, 20 | amountBeforeUSD, 21 | amountAfter, 22 | amountAfterUSD, 23 | percentageChange, 24 | minutesAgo, 25 | ); 26 | }); 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thorgate", 3 | "module": "index.ts", 4 | "type": "module", 5 | "scripts": { 6 | "start": "bun src/index.ts", 7 | "dev": "bun --watch src/index.ts", 8 | "test": "bun test" 9 | }, 10 | "devDependencies": { 11 | "@types/ioredis": "^5.0.0", 12 | "@types/node-cron": "^3.0.11", 13 | "bun-types": "latest" 14 | }, 15 | "peerDependencies": { 16 | "typescript": "5.0.0" 17 | }, 18 | "dependencies": { 19 | "@bull-board/express": "^5.23.0", 20 | "@types/express": "^5.0.0", 21 | "@types/semver": "^7.5.8", 22 | "bullmq": "^5.13.2", 23 | "discord-webhook-node": "^1.1.8", 24 | "dotenv": "^16.4.5", 25 | "express": "^4.21.0", 26 | "ioredis": "^5.3.2", 27 | "node-cron": "^3.0.3", 28 | "pino": "^9.4.0", 29 | "pino-pretty": "^11.2.2", 30 | "semver": "^7.6.3", 31 | "zod": "^3.23.8" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/networkPrice.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "bun:test"; 2 | import { notify, runNetworkPrice } from "../src/jobs/networkPrice"; 3 | 4 | // Test notification for RUNE price in TOR 5 | test("should send a notification for rune_price_in_tor change", async () => { 6 | await notify( 7 | "rune_price_in_tor", 8 | 1.23, 9 | 1.56, 10 | 21.95, 11 | 10, 12 | "https://thornode.ninerealms.com/thorchain/network", 13 | "rune_price_in_tor", 14 | ); 15 | }); 16 | 17 | // Test notification for TOR price in RUNE 18 | test("should send a notification for tor_price_in_rune change", async () => { 19 | await notify( 20 | "tor_price_in_rune", 21 | 0.089, 22 | 0.11, 23 | 23.6, 24 | 10, 25 | "https://thornode.ninerealms.com/thorchain/network", 26 | "tor_price_in_rune", 27 | ); 28 | }); 29 | 30 | // Run the network price check logic 31 | test("should run network price comparison", async () => { 32 | await runNetworkPrice(false); 33 | }, 60000); // 60 seconds timeout for this test 34 | -------------------------------------------------------------------------------- /src/config/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import semver from "semver"; 3 | 4 | const ConfigSchema = z.object({ 5 | NODE_ENV: z 6 | .enum(["development", "production", "test"]) 7 | .default("development"), 8 | PORT: z.string().default("3000").transform(Number), 9 | REDIS_HOST: z.string().default("localhost"), 10 | REDIS_PORT: z.string().default("6379").transform(Number), 11 | REDIS_PASSWORD: z.string().optional(), 12 | DISCORD_WEBHOOK_URL: z.string(), 13 | DISCORD_WEBHOOK_URL_JOB: z.string(), 14 | COINGECKO_API_KEY: z.string().optional(), 15 | COINGECKO_DO_NOT_ALERT: z 16 | .string() 17 | .default("false") 18 | .transform((value) => value === "true"), 19 | APP_VERSION: z 20 | .string() 21 | .optional() 22 | .refine((value) => value === undefined || semver.valid(value) !== null, { 23 | message: "Invalid version format", 24 | }) 25 | .default("0.0.0"), 26 | }); 27 | 28 | type ConfigType = z.infer; 29 | 30 | export { ConfigSchema, ConfigType }; 31 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | 3 | export const formatNumber = (num: number) => { 4 | return num.toLocaleString("en-US", { 5 | style: "decimal", 6 | maximumFractionDigits: 0, 7 | }); 8 | }; 9 | 10 | export function formatNumberPrice(num: number): string { 11 | const absNum = Math.abs(num); 12 | let precision = 2; 13 | 14 | if (absNum >= 1000) precision = 0; 15 | else if (absNum < 0.01) precision = 6; 16 | else if (absNum < 1) precision = 4; 17 | else if (absNum < 10) precision = 3; 18 | 19 | const formatted = new Intl.NumberFormat("en-US", { 20 | style: "currency", 21 | currency: "USD", 22 | minimumFractionDigits: 0, 23 | maximumFractionDigits: precision, 24 | useGrouping: true, 25 | }).format(num); 26 | 27 | return formatted.replace(/\.00$/, ""); 28 | } 29 | 30 | export const DEFAULT_COMPARE_TIMES = [1, 10, 30, 60]; 31 | 32 | export const createHash = (data: any): string => { 33 | return crypto.createHash("sha256").update(JSON.stringify(data)).digest("hex"); 34 | }; 35 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { ConfigSchema, ConfigType } from "./schema"; 3 | 4 | dotenv.config(); 5 | 6 | const parseConfig = (): ConfigType => { 7 | const result = ConfigSchema.safeParse(process.env); 8 | 9 | if (!result.success) { 10 | const errorMessage = result.error.issues 11 | .map((issue) => `${issue.path[0]}: ${issue.message}`) 12 | .join(", "); 13 | throw new Error(`Configuration validation error: ${errorMessage}`); 14 | } 15 | 16 | return result.data; 17 | }; 18 | 19 | const config = parseConfig(); 20 | 21 | export const Config = { 22 | env: config.NODE_ENV, 23 | port: config.PORT, 24 | version: config.APP_VERSION, 25 | redis: { 26 | host: config.REDIS_HOST, 27 | port: config.REDIS_PORT, 28 | password: config.REDIS_PASSWORD, 29 | }, 30 | discord: { 31 | webhookUrl: config.DISCORD_WEBHOOK_URL, 32 | jobWebhookUrl: config.DISCORD_WEBHOOK_URL_JOB, 33 | }, 34 | coingecko: { 35 | apiKey: config.COINGECKO_API_KEY, 36 | doNotAlert: config.COINGECKO_DO_NOT_ALERT, 37 | }, 38 | }; 39 | 40 | export const isProduction = config.NODE_ENV === "production"; 41 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image 2 | on: 3 | release: 4 | types: [published] 5 | # push: 6 | # branches: 7 | # - main 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | jobs: 12 | build-and-push-image: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | - name: Log in to the Container registry 21 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 22 | with: 23 | registry: ${{ env.REGISTRY }} 24 | username: ${{ github.actor }} 25 | password: ${{ secrets.GITHUB_TOKEN }} 26 | - name: Extract metadata (tags, labels) for Docker 27 | id: meta 28 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 29 | with: 30 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 31 | - name: Build and push Docker image 32 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 33 | with: 34 | context: . 35 | push: true 36 | tags: ${{ steps.meta.outputs.tags }} 37 | labels: ${{ steps.meta.outputs.labels }} 38 | build-args: | 39 | VERSION=${{ github.ref_name }} 40 | -------------------------------------------------------------------------------- /src/bullmq/queues.ts: -------------------------------------------------------------------------------- 1 | import { DefaultJobOptions, Queue } from "bullmq"; 2 | import { redisOptionsForQueue } from "../database/redis"; 3 | 4 | const defaultJobOptions: DefaultJobOptions = { 5 | removeOnComplete: { 6 | age: 3600, // 1 hour 7 | count: 100, // Keep the latest 100 jobs 8 | }, 9 | removeOnFail: { 10 | // age: 86400, // 24 hours 11 | count: 100, // Keep the latest 1000 jobs 12 | }, 13 | }; 14 | 15 | export const deleteRepeatableJobs = async (queue: Queue) => { 16 | const repeatableJobs = await queue.getRepeatableJobs(); 17 | for (const job of repeatableJobs) { 18 | await queue.removeRepeatableByKey(job.key); 19 | } 20 | }; 21 | 22 | export const networkPriceQueue = new Queue("network-price", { 23 | connection: redisOptionsForQueue, 24 | defaultJobOptions, 25 | }); 26 | export const thorchainBalanceQueue = new Queue("thorchain-balance", { 27 | connection: redisOptionsForQueue, 28 | defaultJobOptions, 29 | }); 30 | export const poolQueue = new Queue("pool", { 31 | connection: redisOptionsForQueue, 32 | defaultJobOptions, 33 | }); 34 | export const priceQueue = new Queue("price", { 35 | connection: redisOptionsForQueue, 36 | defaultJobOptions, 37 | }); 38 | export const coingeckoComparisonQueue = new Queue("coingecko-comparison", { 39 | connection: redisOptionsForQueue, 40 | defaultJobOptions, 41 | }); 42 | 43 | export const queues: Record = { 44 | "network-price": networkPriceQueue, 45 | "thorchain-balance": thorchainBalanceQueue, 46 | pool: poolQueue, 47 | price: priceQueue, 48 | "coingecko-comparison": coingeckoComparisonQueue, 49 | }; 50 | -------------------------------------------------------------------------------- /src/api/thorchain/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | // Zod Schemas 4 | export const CoinSchema = z.object({ 5 | asset: z.string(), 6 | amount: z.string(), 7 | }); 8 | 9 | export const BalanceItemSchema = z.object({ 10 | denom: z.string(), 11 | amount: z.string(), 12 | }); 13 | 14 | export const BalanceResponseSchema = z.object({ 15 | balances: z.array(BalanceItemSchema), 16 | }); 17 | 18 | export const NetworkSchema = z.object({ 19 | bond_reward_rune: z.string(), 20 | total_reserve: z.string(), 21 | rune_price_in_tor: z.string(), 22 | tor_price_in_rune: z.string(), 23 | }); 24 | 25 | export const PoolItemSchema = z.object({ 26 | asset: z.string(), 27 | balance_asset: z.string(), 28 | balance_rune: z.string(), 29 | pool_units: z.string(), 30 | LP_units: z.string(), 31 | synth_units: z.string(), 32 | status: z.string(), 33 | }); 34 | 35 | export const DerivedPoolItemSchema = z.object({ 36 | asset: z.string(), 37 | balance_asset: z.string(), 38 | balance_rune: z.string(), 39 | derived_depth_bps: z.string(), 40 | status: z.string(), 41 | }); 42 | 43 | // Wrapped 44 | export const PoolSchema = z.array(PoolItemSchema); 45 | export const DerivedPoolSchema = z.array(DerivedPoolItemSchema); 46 | 47 | // TypeScript Interfaces 48 | export type Coin = z.infer; 49 | export type BalanceItem = z.infer; 50 | export type BalanceResponse = z.infer; 51 | export type Network = z.infer; 52 | export type Pool = z.infer; 53 | export type DerivedPool = z.infer; 54 | -------------------------------------------------------------------------------- /tests/api/coingecko.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "bun:test"; 2 | 3 | import { 4 | fetchCoinGeckoPrices, 5 | fetchCoinGeckoList, 6 | mapThorchainAssetToCoinGeckoId, 7 | } from "../../src/api/coingecko"; 8 | 9 | const TEST_ASSET_OVERRIDES: Record = { 10 | "THOR.TOR": "tether", 11 | }; 12 | 13 | describe("CoinGecko API", () => { 14 | it("fetches CoinGecko prices", async () => { 15 | const coinIds = ["bitcoin", "ethereum"]; 16 | const prices = await fetchCoinGeckoPrices(coinIds); 17 | expect(prices.bitcoin).toBeDefined(); 18 | expect(prices.bitcoin.usd).toBeGreaterThan(0); 19 | expect(prices.ethereum).toBeDefined(); 20 | expect(prices.ethereum.usd).toBeGreaterThan(0); 21 | }); 22 | 23 | it("fetches CoinGecko coin list", async () => { 24 | const coinList = await fetchCoinGeckoList(); 25 | expect(coinList.length).toBeGreaterThan(0); 26 | expect(coinList[0]).toHaveProperty("id"); 27 | expect(coinList[0]).toHaveProperty("symbol"); 28 | expect(coinList[0]).toHaveProperty("name"); 29 | }); 30 | 31 | it("maps Thorchain asset to CoinGecko ID", async () => { 32 | const coinList = await fetchCoinGeckoList(); 33 | const thorchainAsset = 34 | "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48"; 35 | const coinGeckoId = mapThorchainAssetToCoinGeckoId( 36 | thorchainAsset, 37 | coinList, 38 | ); 39 | expect(coinGeckoId).toEqual("usd-coin"); 40 | }); 41 | 42 | it("maps Thorchain asset from overrides", () => { 43 | const thorchainAsset = "THOR.TOR"; 44 | const coinGeckoId = mapThorchainAssetToCoinGeckoId(thorchainAsset, []); 45 | expect(coinGeckoId).toEqual(TEST_ASSET_OVERRIDES[thorchainAsset]); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/logger/index.ts: -------------------------------------------------------------------------------- 1 | import { structuredLog } from "./pinoLogger"; 2 | import { sendDiscordNotification } from "./discord"; 3 | import { LogType, LogDetails } from "./types"; 4 | import { notifyAdditionalLock } from "../notifications"; 5 | import { createHash } from "../utils"; 6 | 7 | export const logWorkerMessage = async ( 8 | type: LogType, 9 | queueName: string, 10 | jobId: string, 11 | message: string, 12 | details?: LogDetails, 13 | ) => { 14 | const log = { 15 | queue: queueName, 16 | jobId, 17 | message, 18 | details, 19 | }; 20 | 21 | // Create a unique hash key based on the log details, queueName, and jobId 22 | const hashKey = createHash({ queueName, message, details }); 23 | 24 | const key = `notification-lock:worker:${queueName}:${hashKey}`; 25 | 26 | // Attempt to set a lock using this hash key 27 | let lockAcquired; 28 | try { 29 | lockAcquired = await notifyAdditionalLock(key); 30 | } catch (error) { 31 | structuredLog("error", { 32 | queue: queueName, 33 | jobId, 34 | message: "Failed to set additional notification lock", 35 | details: { error: error }, 36 | }); 37 | } 38 | 39 | // Skip sending additional notification if lock already exists 40 | if (!lockAcquired) { 41 | structuredLog("info", { 42 | queue: queueName, 43 | jobId, 44 | message: "Duplicate notification suppressed by additional lock", 45 | lockKey: key, 46 | details, 47 | }); 48 | return; 49 | } 50 | 51 | structuredLog(type, log); 52 | 53 | await sendDiscordNotification(type, queueName, jobId, message, details); 54 | }; 55 | 56 | export const logMessage = async ( 57 | type: LogType, 58 | message: string, 59 | details?: LogDetails, 60 | ) => { 61 | const log = { 62 | message, 63 | details, 64 | }; 65 | structuredLog(type, log); 66 | }; 67 | -------------------------------------------------------------------------------- /src/api/thorchain/api.ts: -------------------------------------------------------------------------------- 1 | import { APIError } from "../../utils/errors"; 2 | import { z } from "zod"; 3 | 4 | export async function fetchFromAPI( 5 | path: string, 6 | schema: z.Schema, 7 | queryParams?: Record, 8 | ): Promise { 9 | const baseUrl = "https://thornode.ninerealms.com"; 10 | const queryString = queryParams 11 | ? "?" + new URLSearchParams(queryParams).toString() 12 | : ""; 13 | const url = `${baseUrl}${path}${queryString}`; 14 | 15 | try { 16 | const response = await fetch(url, { 17 | method: "GET", 18 | headers: { accept: "application/json", "x-client-id": "thorgate" }, 19 | }); 20 | 21 | if (!response.ok) { 22 | const errorMessage = await getErrorMessage(response); 23 | throw new APIError(response.status, errorMessage, path); 24 | } 25 | 26 | const data = await response.json(); 27 | return schema.parse(data); 28 | } catch (error) { 29 | if (error instanceof APIError) { 30 | throw error; 31 | } else if (error instanceof TypeError) { 32 | throw error; 33 | } else { 34 | throw new Error( 35 | `Unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, 36 | ); 37 | } 38 | } 39 | } 40 | 41 | const getErrorMessage = async (response: Response): Promise => { 42 | try { 43 | const errorData = await response.json(); 44 | if ( 45 | typeof errorData === "object" && 46 | errorData !== null && 47 | "code" in errorData && 48 | "message" in errorData && 49 | "details" in errorData 50 | ) { 51 | return `gRPC error: code = ${errorData.code}, message = ${errorData.message}`; 52 | } else if ( 53 | typeof errorData === "object" && 54 | errorData !== null && 55 | "error" in errorData 56 | ) { 57 | return typeof errorData.error === "string" 58 | ? errorData.error 59 | : "Unknown error occurred (no error message)"; 60 | } else { 61 | return "Unknown error occurred"; 62 | } 63 | } catch { 64 | return response.statusText; 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /src/bullmq/dashboard.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { createBullBoard } from "@bull-board/api"; 3 | import { BullMQAdapter } from "@bull-board/api/bullMQAdapter"; 4 | import { ExpressAdapter } from "@bull-board/express"; 5 | import { 6 | networkPriceQueue, 7 | thorchainBalanceQueue, 8 | poolQueue, 9 | priceQueue, 10 | coingeckoComparisonQueue, 11 | } from "./queues"; 12 | import { Config } from "../config"; 13 | 14 | export const runDashboardServer = async () => { 15 | const serverAdapter = new ExpressAdapter(); 16 | serverAdapter.setBasePath("/"); 17 | 18 | createBullBoard({ 19 | queues: [ 20 | new BullMQAdapter(networkPriceQueue, { 21 | description: "THORChain network price, eg. tor_price_in_rune", 22 | }), 23 | new BullMQAdapter(thorchainBalanceQueue, { 24 | description: 25 | "Balances (RUNE and tokens) of various manually configured wallets", 26 | }), 27 | new BullMQAdapter(poolQueue, { 28 | description: "Pools properties, eg. balance_rune or balance_asset", 29 | }), 30 | new BullMQAdapter(priceQueue, { 31 | description: "Changes in prices on THORChain of various assets", 32 | }), 33 | new BullMQAdapter(coingeckoComparisonQueue, { 34 | description: "Compares THORChain prices to CoinGecko for differences", 35 | }), 36 | ], 37 | serverAdapter: serverAdapter, 38 | options: { 39 | uiConfig: { 40 | boardTitle: `THORGate ${Config.version}`, 41 | boardLogo: { 42 | path: "https://blog.mexc.com/wp-content/uploads/2022/09/1_KkoJRE6ICrE70mNegVeY_Q.png", 43 | width: 50, 44 | height: 50, 45 | }, 46 | favIcon: { 47 | default: 48 | "https://blog.mexc.com/wp-content/uploads/2022/09/1_KkoJRE6ICrE70mNegVeY_Q.png", 49 | alternative: 50 | "https://blog.mexc.com/wp-content/uploads/2022/09/1_KkoJRE6ICrE70mNegVeY_Q.png", 51 | }, 52 | }, 53 | }, 54 | }); 55 | 56 | const app = express(); 57 | app.use("/", serverAdapter.getRouter()); 58 | 59 | app.listen(Config.port, () => { 60 | console.log(`BullMQ Dashboard running on http://localhost:${Config.port}`); 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /src/logger/discord.ts: -------------------------------------------------------------------------------- 1 | import { buildJobNotification } from "../notifications"; 2 | import { Config, isProduction } from "../config"; 3 | import { LogType, LogDetails } from "./types"; 4 | 5 | const DISCORD_MESSAGE_LIMIT = 1000; 6 | const SAFETY_MARGIN = 100; 7 | 8 | // Truncate details if they exceed the message limit 9 | const truncateDetails = (details: string, maxLength: number): string => { 10 | if (details.length <= maxLength) { 11 | return details; 12 | } 13 | return `${details.substring(0, maxLength - 3)}...`; 14 | }; 15 | 16 | // Send a notification to Discord 17 | export const sendDiscordNotification = async ( 18 | type: LogType, 19 | queueName: string, 20 | jobId: string, 21 | message: string, 22 | details?: LogDetails, 23 | ) => { 24 | const { hook, builder } = buildJobNotification(); 25 | 26 | const jobUrl = isProduction 27 | ? `https://thorgate.home.everywhe.re/queue/${queueName}/${jobId}` 28 | : `http://localhost:${Config.port}/queue/${queueName}/${jobId}`; 29 | 30 | const queueUrl = isProduction 31 | ? `https://thorgate.home.everywhe.re/queue/${queueName}` 32 | : `http://localhost:${Config.port}/queue/${queueName}`; 33 | 34 | const embed = builder 35 | .setTitle(`${queueName}`) 36 | .setURL(queueUrl) 37 | .setColor(type === "error" ? 0xff0000 : 0x00ff00) 38 | .addField("ID", `[${jobId}](${jobUrl})`, true) 39 | .addField("Message", message) 40 | .setTimestamp(); 41 | 42 | if (details && Object.keys(details).length > 0) { 43 | if (Object.keys(details).length === 1 && details.logs) { 44 | // Only logs are available, display them directly 45 | const logsAsString = Array.isArray(details.logs) 46 | ? details.logs.join("\n") 47 | : details.logs; 48 | embed.addField( 49 | "Logs", 50 | `\`\`\`\n${truncateDetails(logsAsString, DISCORD_MESSAGE_LIMIT - SAFETY_MARGIN - message.length)}\n\`\`\``, 51 | ); 52 | } else { 53 | const detailsString = JSON.stringify(details, null, 2); 54 | embed.addField( 55 | "Details", 56 | `\`\`\`json\n${truncateDetails(detailsString, DISCORD_MESSAGE_LIMIT - SAFETY_MARGIN - message.length)}\n\`\`\``, 57 | ); 58 | } 59 | } 60 | 61 | await hook.send(embed); 62 | }; 63 | -------------------------------------------------------------------------------- /src/services/prices.ts: -------------------------------------------------------------------------------- 1 | import { findClosestTimeKey, redis } from "../database/redis"; 2 | 3 | const formatAsset = (asset: string): string => { 4 | const formattedAsset = asset.toUpperCase().replace("/", "."); 5 | return formattedAsset === "RUNE" ? "rune_price_in_tor" : formattedAsset; 6 | }; 7 | 8 | const fetchLatestPrice = async (redisKey: string): Promise => { 9 | const { value } = await findClosestTimeKey(redisKey, Date.now()); 10 | if (value === null || value === undefined) { 11 | throw new Error(`No price found for ${redisKey}`); 12 | } 13 | return parseFloat(value); 14 | }; 15 | 16 | const fetchAllPricesAndCalculateAverage = async ( 17 | redisKeyPattern: string, 18 | ): Promise => { 19 | const keys = await redis.keys(redisKeyPattern); 20 | 21 | if (keys.length === 0) { 22 | throw new Error(`No price data found for ${redisKeyPattern}`); 23 | } 24 | 25 | const prices = await Promise.all(keys.map((key) => redis.get(key))); 26 | 27 | const validPrices = prices 28 | .filter((price): price is string => price !== null) 29 | .map((price) => parseFloat(price)) 30 | .filter((price) => !isNaN(price) && price > 0); 31 | 32 | if (validPrices.length === 0) { 33 | throw new Error(`No valid price data found for ${redisKeyPattern}`); 34 | } 35 | 36 | return ( 37 | validPrices.reduce((total, price) => total + price, 0) / validPrices.length 38 | ); 39 | }; 40 | 41 | export const getLatestPriceByAsset = async (asset: string) => { 42 | const formattedAsset = formatAsset(asset); 43 | 44 | if (formattedAsset === "TOR") { 45 | const runePrice = await fetchLatestPrice("price:rune_price_in_tor"); 46 | const torPrice = await fetchLatestPrice("price:tor_price_in_rune"); 47 | return runePrice * torPrice; 48 | } 49 | return await fetchLatestPrice(`price:${formattedAsset}`); 50 | }; 51 | 52 | export const getAveragePriceByAsset = async ( 53 | asset: string, 54 | ): Promise => { 55 | const formattedAsset = formatAsset(asset); 56 | const redisKeyPattern = `price:${formattedAsset}:*`; 57 | 58 | if (formattedAsset === "TOR") { 59 | const runeAveragePrice = await fetchAllPricesAndCalculateAverage( 60 | "price:rune_price_in_tor:*", 61 | ); 62 | const torAveragePrice = await fetchAllPricesAndCalculateAverage( 63 | "price:tor_price_in_rune:*", 64 | ); 65 | return runeAveragePrice * torAveragePrice; 66 | } 67 | return await fetchAllPricesAndCalculateAverage(redisKeyPattern); 68 | }; 69 | -------------------------------------------------------------------------------- /src/database/redis/index.ts: -------------------------------------------------------------------------------- 1 | import Redis, { RedisOptions } from "ioredis"; 2 | import { Config } from "../../config"; 3 | import { logWorkerMessage } from "../../logger"; 4 | 5 | const redisOptions: RedisOptions = { 6 | host: Config.redis.host, 7 | port: Config.redis.port, 8 | password: Config.redis.password, 9 | maxRetriesPerRequest: null, 10 | retryStrategy: (times: number) => { 11 | return Math.max(Math.min(Math.exp(times), 20000), 1000); 12 | }, 13 | }; 14 | 15 | // Queue should fail quickly on request issues 16 | export const redisOptionsForQueue: RedisOptions = { 17 | ...redisOptions, 18 | enableOfflineQueue: false, 19 | }; 20 | 21 | // Worker should wait indefinitely until reconnection 22 | export const redisOptionsForWorker: RedisOptions = { 23 | ...redisOptions, 24 | enableOfflineQueue: true, 25 | }; 26 | 27 | // Redis client for other usages (e.g., health checks) 28 | export const redis = new Redis(redisOptions); 29 | 30 | // Error logging for Redis connections 31 | redis.on("error", (err: Error) => { 32 | logWorkerMessage("error", "redis", "N/A", "Redis connection error", err); 33 | }); 34 | 35 | // Find closest time key function 36 | export const findClosestTimeKey = async ( 37 | redisKey: string, 38 | targetTime: number, 39 | margin = 5, 40 | ): Promise<{ key: string; value: string }> => { 41 | const startTime = targetTime - margin * 60000; 42 | const endTime = targetTime + margin * 60000; 43 | const pattern = `${redisKey}:time:*`; 44 | let closestKey = ""; 45 | let closestValue = ""; 46 | let closestDiff = Number.MAX_SAFE_INTEGER; 47 | 48 | let cursor = "0"; 49 | do { 50 | const [newCursor, keys] = await redis.scan( 51 | cursor, 52 | "MATCH", 53 | pattern, 54 | "COUNT", 55 | 100, 56 | ); 57 | cursor = newCursor; 58 | 59 | for (const key of keys) { 60 | const timePartString = key.split(":").pop(); 61 | if (timePartString) { 62 | const timePart = parseInt(timePartString, 10); 63 | if (timePart >= startTime && timePart <= endTime) { 64 | const diff = Math.abs(targetTime - timePart); 65 | if (diff < closestDiff) { 66 | const value = await redis.get(key); 67 | if (value !== null) { 68 | closestKey = key; 69 | closestValue = value; 70 | closestDiff = diff; 71 | } 72 | } 73 | } 74 | } 75 | } 76 | } while (cursor !== "0"); 77 | 78 | return { key: closestKey, value: closestValue }; 79 | }; 80 | -------------------------------------------------------------------------------- /src/notifications/index.ts: -------------------------------------------------------------------------------- 1 | import { Webhook, MessageBuilder } from "discord-webhook-node"; 2 | import { redis } from "../database/redis"; 3 | import { Config } from "../config"; 4 | 5 | const APP_NAME = `THORGate ${Config.version}`.trim(); 6 | const AVATAR_URL = 7 | "https://blog.mexc.com/wp-content/uploads/2022/09/1_KkoJRE6ICrE70mNegVeY_Q.png"; 8 | 9 | const ADDITIONAL_NOTIFICATION_LOCK_TTL = 60 * 60 * 24; 10 | const NOTIFICATION_LOCK_TTL = 60 * 60 * 1; 11 | 12 | const createWebhook = (url: string): Webhook => { 13 | const hook = new Webhook(url); 14 | hook.setUsername(APP_NAME); 15 | hook.setAvatar(AVATAR_URL); 16 | return hook; 17 | }; 18 | 19 | export const buildNotification = (): { 20 | hook: Webhook; 21 | builder: MessageBuilder; 22 | } => { 23 | const hook = createWebhook(Config.discord.webhookUrl); 24 | const builder = new MessageBuilder(); 25 | return { hook, builder }; 26 | }; 27 | 28 | export const buildJobNotification = (): { 29 | hook: Webhook; 30 | builder: MessageBuilder; 31 | } => { 32 | const hook = createWebhook(Config.discord.jobWebhookUrl); 33 | const builder = new MessageBuilder(); 34 | return { hook, builder }; 35 | }; 36 | 37 | export const setNotificationLock = async ( 38 | resource: string, 39 | ttl: number = NOTIFICATION_LOCK_TTL, 40 | ): Promise => { 41 | return redis.set(`notification-lock:${resource}`, "1", "EX", ttl); 42 | }; 43 | 44 | export const getNotificationLock = async ( 45 | resource: string, 46 | ): Promise => { 47 | return redis.get(`notification-lock:${resource}`); 48 | }; 49 | 50 | export const notifyLock = async (resource: string, ttl: number = 3600) => { 51 | const lock = await getNotificationLock(resource); 52 | if (lock) { 53 | return false; 54 | } 55 | await setNotificationLock(resource, ttl); 56 | return true; 57 | }; 58 | 59 | export const setAdditionalNotificationLock = async ( 60 | key: string, 61 | ttl: number = ADDITIONAL_NOTIFICATION_LOCK_TTL, 62 | ): Promise => { 63 | return redis.set(key, "1", "EX", ttl); 64 | }; 65 | 66 | export const getAdditionalNotificationLock = async ( 67 | key: string, 68 | ): Promise => { 69 | return await redis.get(key); 70 | }; 71 | 72 | export const notifyAdditionalLock = async ( 73 | key: string, 74 | ttl: number = ADDITIONAL_NOTIFICATION_LOCK_TTL, 75 | ) => { 76 | const lock = await getAdditionalNotificationLock(key); 77 | if (lock) { 78 | return false; 79 | } 80 | await setAdditionalNotificationLock(key, ttl); 81 | return true; 82 | }; 83 | -------------------------------------------------------------------------------- /src/bullmq/scheduler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | networkPriceQueue, 3 | thorchainBalanceQueue, 4 | poolQueue, 5 | priceQueue, 6 | coingeckoComparisonQueue, 7 | deleteRepeatableJobs, 8 | } from "./queues"; 9 | import "./workers"; 10 | import { fetchPools } from "../api/thorchain"; 11 | import { Config, isProduction } from "../config"; 12 | import { Pool } from "../api/thorchain/schemas"; 13 | import { 14 | wallets as thorchainBalanceWallets, 15 | Wallet, 16 | } from "../jobs/thorchainBalance"; 17 | 18 | const SCHEDULE_PATTERN = "* * * * *"; 19 | const IMMEDIATELY = !isProduction; 20 | 21 | const createJobOptions = (jobId: string) => ({ 22 | repeat: { 23 | pattern: SCHEDULE_PATTERN, 24 | immediately: IMMEDIATELY, 25 | jobId, 26 | }, 27 | }); 28 | 29 | const scheduleNetworkPriceJob = async () => { 30 | await deleteRepeatableJobs(networkPriceQueue); 31 | networkPriceQueue.add( 32 | "network-price-job", 33 | {}, 34 | createJobOptions("network-price-job"), 35 | ); 36 | }; 37 | 38 | const scheduleThorchainBalanceJobs = async (wallets: Wallet[]) => { 39 | await deleteRepeatableJobs(thorchainBalanceQueue); 40 | for (const wallet of wallets) { 41 | thorchainBalanceQueue.add( 42 | "thorchain-balance-job", 43 | { wallet }, 44 | createJobOptions(`thorchain-balance-job-${wallet.address}`), 45 | ); 46 | } 47 | }; 48 | 49 | const schedulePoolJobs = async () => { 50 | const pools: Pool[] = (await fetchPools()) as Pool[]; 51 | // const derivedPools: DerivedPool[] = (await fetchDerivedPools()) as DerivedPool[]; 52 | 53 | await deleteRepeatableJobs(poolQueue); 54 | await schedulePools(poolQueue, pools, false); 55 | // await schedulePools(poolQueue, derivedPools, true); 56 | }; 57 | 58 | const schedulePools = async ( 59 | queue: typeof poolQueue, 60 | pools: Pool[], 61 | derived: boolean, 62 | ) => { 63 | for (const pool of pools) { 64 | await queue.add( 65 | "pool-job", 66 | { 67 | asset: pool.asset, 68 | derived, 69 | }, 70 | createJobOptions(`pool-job-${pool.asset}`), 71 | ); 72 | } 73 | }; 74 | 75 | const schedulePriceMonitoringJob = async () => { 76 | await deleteRepeatableJobs(priceQueue); 77 | priceQueue.add("price-job", {}, createJobOptions("price-job")); 78 | }; 79 | 80 | const scheduleCoingeckoComparisonJob = async () => { 81 | await deleteRepeatableJobs(coingeckoComparisonQueue); 82 | coingeckoComparisonQueue.add( 83 | "coingecko-job", 84 | { doNotAlert: Config.coingecko.doNotAlert }, 85 | createJobOptions("coingecko-job"), 86 | ); 87 | }; 88 | 89 | export const schedule = async () => { 90 | await scheduleNetworkPriceJob(); 91 | await scheduleThorchainBalanceJobs(thorchainBalanceWallets); 92 | await schedulePoolJobs(); 93 | await schedulePriceMonitoringJob(); 94 | await scheduleCoingeckoComparisonJob(); 95 | }; 96 | -------------------------------------------------------------------------------- /tests/coingeckoComparison.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "bun:test"; 2 | import { 3 | notifyPriceDifference, 4 | calculatePercentageDifference, 5 | getSeverityLevel, 6 | runThorchainCoingeckoPriceComparison, 7 | } from "../src/jobs/coingeckoComparison"; 8 | 9 | const testCases = [ 10 | { 11 | description: "should send a LOW severity notification", 12 | asset: "DOGE.DOGE", 13 | thorchainPrice: 0.1, 14 | coingeckoPrice: 0.105, 15 | coinGeckoId: "dogecoin", 16 | expectedSeverity: "Low", 17 | // Difference is 5% which falls below 10% 18 | }, 19 | { 20 | description: "should send a MEDIUM severity notification", 21 | asset: "ETH.ETH", 22 | thorchainPrice: 2000, 23 | coingeckoPrice: 2300, 24 | coinGeckoId: "ethereum", 25 | expectedSeverity: "Medium", 26 | // Difference is ~13% which falls between 10% to 20% 27 | }, 28 | { 29 | description: "should send a HIGH severity notification", 30 | asset: "GAIA.ATOM", 31 | thorchainPrice: 10, 32 | coingeckoPrice: 12.5, 33 | coinGeckoId: "cosmos", 34 | expectedSeverity: "High", 35 | // Difference is 25% which falls between 20% to 50% 36 | }, 37 | { 38 | description: "should send a CRITICAL severity notification", 39 | asset: "DOGE.DOGE", 40 | thorchainPrice: 0.1, 41 | coingeckoPrice: 0.2, 42 | coinGeckoId: "dogecoin", 43 | expectedSeverity: "Critical", 44 | // Difference is 100% which is above 50% 45 | }, 46 | ]; 47 | 48 | for (const testCase of testCases) { 49 | const { 50 | description, 51 | asset, 52 | thorchainPrice, 53 | coingeckoPrice, 54 | coinGeckoId, 55 | expectedSeverity, 56 | } = testCase; 57 | 58 | test(description, async () => { 59 | const percentageDifference = calculatePercentageDifference( 60 | thorchainPrice, 61 | coingeckoPrice, 62 | ); 63 | const severity = getSeverityLevel(percentageDifference); 64 | 65 | expect(severity).toBe(expectedSeverity); 66 | 67 | await notifyPriceDifference( 68 | asset, 69 | thorchainPrice, 70 | coingeckoPrice, 71 | percentageDifference, 72 | coinGeckoId, 73 | 1, 74 | severity, 75 | ); 76 | }); 77 | } 78 | 79 | // Test for calculating the percentage difference correctly 80 | test("should calculate the percentage difference correctly", () => { 81 | const price1 = 200; 82 | const price2 = 220; 83 | const percentageDifference = calculatePercentageDifference(price1, price2); 84 | expect(percentageDifference).toBeCloseTo(9.52, 2); 85 | }); 86 | 87 | // Test for determining severity level correctly 88 | test("should determine severity level correctly", () => { 89 | expect(getSeverityLevel(55)).toBe("Critical"); 90 | expect(getSeverityLevel(25)).toBe("High"); 91 | expect(getSeverityLevel(15)).toBe("Medium"); 92 | expect(getSeverityLevel(5)).toBe("Low"); 93 | }); 94 | 95 | // Run the THORChain-CoinGecko price comparison 96 | test("should run THORChain-CoinGecko price comparison", async () => { 97 | await runThorchainCoingeckoPriceComparison(false); 98 | }, 60000); // 60 seconds timeout for this test 99 | -------------------------------------------------------------------------------- /tests/api/thorchain.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "bun:test"; 2 | 3 | import { 4 | fetchBalances, 5 | fetchNetwork, 6 | fetchAllPools, 7 | fetchPools, 8 | fetchDerivedPools, 9 | fetchPool, 10 | fetchDerivedPool, 11 | calculatePriceInUSD, 12 | fetchRuneUSDPrice, 13 | } from "../../src/api/thorchain"; 14 | import { 15 | NetworkSchema, 16 | PoolItemSchema, 17 | DerivedPoolItemSchema, 18 | Pool, 19 | } from "../../src/api/thorchain/schemas"; 20 | 21 | describe("Thorchain API", () => { 22 | it("fetches balances", async () => { 23 | const address = "thor1g98cy3n9mmjrpn0sxmn63lztelera37n8n67c0"; 24 | const balances = await fetchBalances(address); 25 | expect(balances.length).toBeGreaterThan(0); 26 | balances.forEach((balance) => { 27 | expect(balance).toHaveProperty("denom"); 28 | expect(balance).toHaveProperty("amount"); 29 | }); 30 | }); 31 | 32 | it("fetches network data", async () => { 33 | const network = await fetchNetwork(); 34 | expect(() => NetworkSchema.parse(network)).not.toThrow(); 35 | }); 36 | 37 | it("fetches all pools", async () => { 38 | const allPools = await fetchAllPools(); 39 | expect(allPools.length).toBeGreaterThan(0); 40 | allPools.forEach((pool) => { 41 | expect(pool).toHaveProperty("asset"); 42 | expect(pool).toHaveProperty("balance_asset"); 43 | expect(pool).toHaveProperty("balance_rune"); 44 | }); 45 | }); 46 | 47 | it("fetches pools", async () => { 48 | const pools = await fetchPools(); 49 | expect(pools.length).toBeGreaterThan(0); 50 | pools.forEach((pool) => { 51 | expect(() => PoolItemSchema.parse(pool)).not.toThrow(); 52 | }); 53 | }); 54 | 55 | it("fetches derived pools", async () => { 56 | const derivedPools = await fetchDerivedPools(); 57 | expect(derivedPools.length).toBeGreaterThan(0); 58 | derivedPools.forEach((pool) => { 59 | expect(() => DerivedPoolItemSchema.parse(pool)).not.toThrow(); 60 | }); 61 | }); 62 | 63 | it("fetches a specific pool", async () => { 64 | const asset = "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48"; 65 | const pool = await fetchPool(asset); 66 | expect(() => PoolItemSchema.parse(pool)).not.toThrow(); 67 | }); 68 | 69 | it("fetches a specific derived pool", async () => { 70 | const asset = "THOR.BTC"; 71 | const derivedPool = await fetchDerivedPool(asset); 72 | expect(() => DerivedPoolItemSchema.parse(derivedPool)).not.toThrow(); 73 | }); 74 | 75 | it("calculates price in USD", () => { 76 | const pool: Pool = { 77 | asset: "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48", 78 | balance_asset: "1000000", 79 | balance_rune: "5000000", 80 | pool_units: "38265621300860", 81 | LP_units: "30287230352485", 82 | synth_units: "7978390948375", 83 | status: "Available", 84 | }; 85 | const runePriceInUsd = 5; 86 | const priceInUsd = calculatePriceInUSD(pool, runePriceInUsd); 87 | expect(priceInUsd).toBeCloseTo(25, 2); 88 | }); 89 | 90 | it("fetches Rune USD price", async () => { 91 | const allPools = await fetchAllPools(); 92 | const runeUsdPrice = await fetchRuneUSDPrice(allPools); 93 | expect(runeUsdPrice).toBeGreaterThan(0); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/api/coingecko/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CoinGeckoPrice, 3 | CoinGeckoCoinItem, 4 | CoinGeckoPriceSchema, 5 | CoinGeckoCoinSchema, 6 | } from "./schemas"; 7 | import { fetchFromAPI } from "./api"; 8 | 9 | const COINGECKO_API_URL = "https://pro-api.coingecko.com/api/v3"; 10 | 11 | const ASSET_OVERRIDES: Record = { 12 | "THOR.TOR": "tether", 13 | "AVAX.SOL-0XFE6B19286885A4F7F55ADAD09C3CD1F906D2478F": "solana", 14 | "AVAX.USDC-0XB97EF9EF8734C71904D8002F8B6BC66DD9C48A6E": "usd-coin", 15 | "AVAX.USDT-0X9702230A8EA53601F5CD2DC00FDBC13D4DF4A8C7": "tether", 16 | "BSC.USDC-0X8AC76A51CC950D9822D68B83FE1AD97B32CD580D": "usd-coin", 17 | "BSC.USDT-0X55D398326F99059FF775485246999027B3197955": "tether", 18 | "ETH.AAVE-0X7FC66500C84A76AD7E9C93437BFC5AC33E2DDAE9": "aave", 19 | "ETH.DAI-0X6B175474E89094C44DA98B954EEDEAC495271D0F": "dai", 20 | "ETH.DPI-0X1494CA1F11D487C2BBE4543E90080AEBA4BA3C2B": "defipulse-index", 21 | "ETH.FOX-0XC770EEFAD204B5180DF6A14EE197D99D808EE52D": "shapeshift-fox-token", 22 | "ETH.GUSD-0X056FD409E1D7A124BD7017459DFEA2F387B6D5CD": "gemini-dollar", 23 | "ETH.LINK-0X514910771AF9CA656AF840DFF83E8264ECF986CA": "chainlink", 24 | "ETH.LUSD-0X5F98805A4E8BE255A32880FDEC7F6728C6568BA0": "liquity-usd", 25 | "ETH.SNX-0XC011A73EE8576FB46F5E1C5751CA3B9FE0AF2A6F": "havven", 26 | "ETH.TGT-0X108A850856DB3F85D0269A2693D896B394C80325": "thorwallet", 27 | "ETH.THOR-0XA5F2211B9B8170F694421F2046281775E8468044": "thorswap", 28 | "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48": "usd-coin", 29 | "ETH.USDP-0X8E870D67F660D95D5BE530380D0EC0BD388289E1": "paxos-standard", 30 | "ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7": "tether", 31 | "ETH.WBTC-0X2260FAC5E5542A773AA44FBCFEDF7C193BC2C599": "wrapped-bitcoin", 32 | "ETH.XRUNE-0X69FA0FEE221AD11012BAB0FDB45D444D3D2CE71C": "thorstarter", 33 | }; 34 | 35 | export const fetchCoinGeckoPrices = async ( 36 | coinIds: string[], 37 | ): Promise => { 38 | const url = `${COINGECKO_API_URL}/simple/price?ids=${coinIds.join(",")}&vs_currencies=usd`; 39 | return fetchFromAPI(url, CoinGeckoPriceSchema); 40 | }; 41 | 42 | export const fetchCoinGeckoList = async (): Promise => { 43 | const url = `${COINGECKO_API_URL}/coins/list`; 44 | return fetchFromAPI(url, CoinGeckoCoinSchema); 45 | }; 46 | 47 | export const mapThorchainAssetToCoinGeckoId = ( 48 | thorchainAsset: string, 49 | coinList: CoinGeckoCoinItem[], 50 | ): string | null => { 51 | if (ASSET_OVERRIDES[thorchainAsset]) { 52 | return ASSET_OVERRIDES[thorchainAsset]; 53 | } 54 | 55 | const [_, symbol] = thorchainAsset.split("."); 56 | const normalizedSymbol = symbol.toLowerCase(); 57 | 58 | const specialCases: Record = { 59 | btc: "bitcoin", 60 | eth: "ethereum", 61 | doge: "dogecoin", 62 | bnb: "binancecoin", 63 | avax: "avalanche-2", 64 | bch: "bitcoin-cash", 65 | ltc: "litecoin", 66 | atom: "cosmos", 67 | }; 68 | 69 | if (specialCases[normalizedSymbol]) { 70 | return specialCases[normalizedSymbol]; 71 | } 72 | 73 | const matchedCoin = coinList.find( 74 | (coin) => 75 | coin.symbol.toLowerCase() === normalizedSymbol || 76 | coin.id.toLowerCase() === normalizedSymbol || 77 | coin.name.toLowerCase() === normalizedSymbol, 78 | ); 79 | 80 | return matchedCoin ? matchedCoin.id : null; 81 | }; 82 | -------------------------------------------------------------------------------- /src/api/thorchain/index.ts: -------------------------------------------------------------------------------- 1 | import { fetchFromAPI } from "./api"; 2 | import { 3 | BalanceItem, 4 | BalanceResponse, 5 | Network, 6 | Pool, 7 | DerivedPool, 8 | BalanceResponseSchema, 9 | NetworkSchema, 10 | PoolSchema, 11 | DerivedPoolSchema, 12 | PoolItemSchema, 13 | DerivedPoolItemSchema, 14 | } from "./schemas"; 15 | 16 | export const fetchBalances = async ( 17 | address: string, 18 | ): Promise => { 19 | const path = `/cosmos/bank/v1beta1/balances/${address}`; 20 | const data = await fetchFromAPI(path, BalanceResponseSchema); 21 | return data.balances.map((balance) => ({ 22 | denom: balance.denom, 23 | amount: (parseFloat(balance.amount) / 1e8).toString(), 24 | })); 25 | }; 26 | 27 | export const fetchNetwork = async (): Promise => { 28 | return fetchFromAPI("/thorchain/network", NetworkSchema); 29 | }; 30 | 31 | export const fetchAllPools = async ( 32 | availableOnly = true, 33 | ): Promise<(Pool | DerivedPool)[]> => { 34 | const [pools, derivedPools] = await Promise.all([ 35 | fetchPools(availableOnly), 36 | fetchDerivedPools(availableOnly), 37 | ]); 38 | 39 | return [...pools, ...derivedPools]; 40 | }; 41 | 42 | export const fetchPools = async (availableOnly = true): Promise => { 43 | const pools = await fetchFromAPI("/thorchain/pools", PoolSchema); 44 | if (availableOnly) { 45 | return pools.filter((pool) => pool.status === "Available"); 46 | } 47 | return pools; 48 | }; 49 | 50 | export const fetchDerivedPools = async ( 51 | availableOnly = true, 52 | ): Promise => { 53 | const derivedPools = await fetchFromAPI( 54 | "/thorchain/dpools", 55 | DerivedPoolSchema, 56 | ); 57 | if (availableOnly) { 58 | return derivedPools.filter((pool) => pool.status === "Available"); 59 | } 60 | return derivedPools; 61 | }; 62 | 63 | export const fetchPool = async (asset: string): Promise => { 64 | return fetchFromAPI(`/thorchain/pool/${asset}`, PoolItemSchema); 65 | }; 66 | 67 | export const fetchDerivedPool = async (asset: string): Promise => { 68 | return fetchFromAPI(`/thorchain/dpool/${asset}`, DerivedPoolItemSchema); 69 | }; 70 | 71 | export const calculatePriceInUSD = ( 72 | pool: Pool | DerivedPool, 73 | runePriceInUsd: number, 74 | ): number | null => { 75 | const balanceRune = parseFloat(pool.balance_rune); 76 | const balanceAsset = parseFloat(pool.balance_asset); 77 | 78 | if (isNaN(balanceRune) || isNaN(balanceAsset) || balanceAsset === 0) { 79 | throw new Error( 80 | `Invalid pool balances: ${pool.balance_rune}, ${pool.balance_asset}`, 81 | ); 82 | } 83 | 84 | const priceInRune = balanceRune / balanceAsset; 85 | return priceInRune * runePriceInUsd; 86 | }; 87 | 88 | export const fetchRuneUSDPrice = async ( 89 | pools: (Pool | DerivedPool)[], 90 | ): Promise => { 91 | const pool = pools.find( 92 | (p) => p.asset === "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48", 93 | ); 94 | if (!pool) { 95 | throw new Error( 96 | "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48 pool not found", 97 | ); 98 | } 99 | 100 | const balanceRune = parseFloat(pool.balance_rune); 101 | const balanceAsset = parseFloat(pool.balance_asset); 102 | 103 | if (isNaN(balanceRune) || isNaN(balanceAsset) || balanceAsset === 0) { 104 | throw new Error( 105 | `Invalid balances for price calculation: ${pool.balance_rune}, ${pool.balance_asset}`, 106 | ); 107 | } 108 | 109 | return balanceAsset / balanceRune; 110 | }; 111 | -------------------------------------------------------------------------------- /src/bullmq/workers.ts: -------------------------------------------------------------------------------- 1 | import { errorToJSON, Job, Queue, Worker } from "bullmq"; 2 | import { redisOptionsForWorker } from "../database/redis"; 3 | import { queues } from "./queues"; 4 | import { logWorkerMessage } from "../logger"; 5 | import { processThorchainBalanceJob } from "../jobs/thorchainBalance"; 6 | import { processNetworkPriceJob } from "../jobs/networkPrice"; 7 | import { processPoolJob } from "../jobs/pool"; 8 | import { processCoingeckoComparisonJob } from "../jobs/coingeckoComparison"; 9 | 10 | const workers: Worker[] = []; 11 | 12 | const networkPriceWorker = new Worker( 13 | "network-price", 14 | async (job) => processNetworkPriceJob(job), 15 | { connection: redisOptionsForWorker }, 16 | ); 17 | workers.push(networkPriceWorker); 18 | 19 | const thorchainBalanceWorker = new Worker( 20 | "thorchain-balance", 21 | async (job) => processThorchainBalanceJob(job), 22 | { connection: redisOptionsForWorker }, 23 | ); 24 | workers.push(thorchainBalanceWorker); 25 | 26 | const poolWorker = new Worker("pool", async (job) => processPoolJob(job), { 27 | connection: redisOptionsForWorker, 28 | }); 29 | workers.push(poolWorker); 30 | 31 | const priceWorker = new Worker( 32 | "price", 33 | async (job) => processNetworkPriceJob(job), 34 | { 35 | connection: redisOptionsForWorker, 36 | }, 37 | ); 38 | workers.push(priceWorker); 39 | 40 | const coingeckoComparisonWorker = new Worker( 41 | "coingecko-comparison", 42 | async (job) => processCoingeckoComparisonJob(job), 43 | { connection: redisOptionsForWorker }, 44 | ); 45 | workers.push(coingeckoComparisonWorker); 46 | 47 | const setupWorkerHandlers = ( 48 | workers: Worker[], 49 | queues: { [key: string]: Queue }, 50 | ) => { 51 | workers.forEach((worker) => { 52 | worker.on("error", (err: Error) => { 53 | logWorkerMessage("error", worker.name, "N/A", "Worker error", err); 54 | }); 55 | 56 | worker.on("completed", async (job: Job) => { 57 | const queueName = job.queueName || "N/A"; 58 | const jobId = job.id || "N/A"; 59 | 60 | if (!queues[queueName]) { 61 | await logWorkerMessage( 62 | "error", 63 | queueName, 64 | jobId, 65 | "Queue not found for job", 66 | ); 67 | return; 68 | } 69 | 70 | if (!job.id) { 71 | await logWorkerMessage( 72 | "error", 73 | queueName, 74 | "N/A", 75 | "Job or job ID not found", 76 | ); 77 | return; 78 | } 79 | 80 | try { 81 | const queue = queues[queueName]; 82 | const { logs } = await queue.getJobLogs(job.id); 83 | 84 | if (logs.length > 0) { 85 | const onlyNotificationLocks = logs.every((log) => 86 | log.includes("Notification lock"), 87 | ); 88 | 89 | if (!onlyNotificationLocks) { 90 | await logWorkerMessage("info", queueName, job.id, "Job completed", { 91 | logs, 92 | }); 93 | } 94 | } 95 | } catch (error: any) { 96 | await logWorkerMessage( 97 | "error", 98 | queueName, 99 | job.id, 100 | `Error getting logs: ${error}`, 101 | error, 102 | ); 103 | } 104 | }); 105 | 106 | worker.on("failed", async (job: Job | undefined, err) => { 107 | const queueName = job?.queueName || "N/A"; 108 | const jobId = job?.id || "N/A"; 109 | await logWorkerMessage("error", queueName, jobId, "Job failed", { 110 | error: err, 111 | }); 112 | }); 113 | }); 114 | }; 115 | 116 | setupWorkerHandlers(workers, queues); 117 | 118 | const gracefulShutdown = async (signal: string) => { 119 | console.log(`Received ${signal}, closing workers...`); 120 | for (const worker of workers) { 121 | await worker.close(); 122 | } 123 | console.log("All workers closed. Exiting process."); 124 | process.exit(0); 125 | }; 126 | 127 | process.on("SIGINT", () => gracefulShutdown("SIGINT")); 128 | process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); 129 | process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); 130 | 131 | process.on("uncaughtException", (err) => { 132 | logWorkerMessage("error", "global", "N/A", "Uncaught exception", err); 133 | }); 134 | 135 | process.on("unhandledRejection", (reason, promise) => { 136 | logWorkerMessage("error", "global", "N/A", "Unhandled rejection", { 137 | promise, 138 | reason, 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /src/jobs/pool.ts: -------------------------------------------------------------------------------- 1 | import { buildNotification, notifyLock } from "../notifications"; 2 | import { findClosestTimeKey, redis } from "../database/redis"; 3 | import { fetchDerivedPool, fetchPool } from "../api/thorchain"; 4 | import { formatNumber } from "../utils"; 5 | import { Job } from "bullmq"; 6 | import { Pool } from "../api/thorchain/schemas"; 7 | 8 | const NOTIFICATION_LOCK_DURATION = 2 * 60 * 60; // 2 hours 9 | 10 | interface Thresholds { 11 | [key: string]: { 12 | percentage: number; 13 | }; 14 | } 15 | 16 | const propertyThresholds: Thresholds = { 17 | balance_asset: { percentage: 50 }, 18 | balance_rune: { percentage: 50 }, 19 | }; 20 | 21 | interface PropertyChangeDetail { 22 | property: string; 23 | currentValue: number; 24 | historicalValues: { 25 | [compareTime: number]: { 26 | historicalValue: number; 27 | percentageDifference: number; 28 | }; 29 | }; 30 | } 31 | 32 | const compareAndNotifyPoolChange = async ( 33 | asset: string, 34 | derived: boolean, 35 | compareTimes: number[], 36 | job: Job, 37 | ): Promise => { 38 | const currentTime = Date.now(); 39 | 40 | const pool: Pool = derived 41 | ? await fetchDerivedPool(asset) 42 | : await fetchPool(asset); 43 | 44 | if (!pool) { 45 | throw new Error(`Pool ${asset} not found.`); 46 | } 47 | 48 | const propertyChangeDetails: PropertyChangeDetail[] = []; 49 | 50 | for (const property of Object.keys(propertyThresholds)) { 51 | if (!pool.hasOwnProperty(property)) continue; 52 | 53 | const currentValue = Number(pool[property]) / 1e8; 54 | const redisKey = `pool:${pool.asset}:${property}`; 55 | 56 | const historicalValues: PropertyChangeDetail["historicalValues"] = {}; 57 | 58 | for (const time of compareTimes) { 59 | const { key, value } = await findClosestTimeKey( 60 | redisKey, 61 | currentTime - time * 60000, 62 | ); 63 | 64 | const historicalValue = Number(value); 65 | 66 | if (!key || isNaN(historicalValue) || historicalValue <= 0) { 67 | await job.log( 68 | `No valid historical value found for ${redisKey} at ${time} minutes ago.`, 69 | ); 70 | continue; 71 | } 72 | 73 | const diff = Math.abs(currentValue - historicalValue); 74 | const percentageDifference = (diff * 100) / historicalValue; 75 | historicalValues[time] = { historicalValue, percentageDifference }; 76 | 77 | if (percentageDifference >= propertyThresholds[property].percentage) { 78 | await job.log( 79 | `Significant change detected for ${pool.asset} in property ${property}`, 80 | ); 81 | const notificationResult = await sendPoolChangeNotification( 82 | pool.asset, 83 | property, 84 | historicalValue, 85 | currentValue, 86 | percentageDifference, 87 | time, 88 | ); 89 | 90 | if (notificationResult.notified) { 91 | await job.log( 92 | `Notified: Significant change in ${property} of ${pool.asset}: ${percentageDifference.toFixed(2)}% (${historicalValue} -> ${currentValue}) over the last ${time} minutes.`, 93 | ); 94 | } else { 95 | await job.log( 96 | `Notification lock for ${notificationResult.redisKey} already exists.`, 97 | ); 98 | } 99 | } 100 | 101 | const progress = Math.round(((time + 1) / compareTimes.length) * 100); 102 | await job.updateProgress(progress); 103 | } 104 | 105 | propertyChangeDetails.push({ 106 | property, 107 | currentValue, 108 | historicalValues, 109 | }); 110 | 111 | await redis.set( 112 | `${redisKey}:time:${currentTime}`, 113 | currentValue.toString(), 114 | "EX", 115 | 7200, 116 | ); // 2 hours TTL 117 | } 118 | 119 | return propertyChangeDetails; 120 | }; 121 | 122 | const sendPoolChangeNotification = async ( 123 | pool: string, 124 | property: string, 125 | valueBefore: number, 126 | valueAfter: number, 127 | percentageChange: number, 128 | minutesAgo: number, 129 | ) => { 130 | const redisKey = `pool_change:${pool}:${property}`; 131 | if (!(await notifyLock(redisKey, NOTIFICATION_LOCK_DURATION))) { 132 | return { notified: false, redisKey }; 133 | } 134 | 135 | const { hook, builder } = buildNotification(); 136 | 137 | const embed = builder 138 | .setAuthor( 139 | "Pool Property Change", 140 | "https://cdn0.iconfinder.com/data/icons/digital-asset-lineal-color/64/Liquidity_Pool_system-512.png", 141 | ) 142 | .setTitle( 143 | `${pool.split("-")[0]}: ${property} Changed by ${percentageChange.toFixed(0)}%`, 144 | ) 145 | .setURL(`https://viewblock.io/thorchain/pool/${pool}`) 146 | .addField("Before", `${formatNumber(valueBefore)}`, true) 147 | .addField("Now", `${formatNumber(valueAfter)}`, true) 148 | .addField( 149 | "Change", 150 | `${valueAfter > valueBefore ? "+" : ""}${formatNumber(valueAfter - valueBefore)}`, 151 | true, 152 | ) 153 | .setColor("#FFFF00") 154 | .setThumbnail( 155 | `https://static.thorswap.net/token-list/images/${pool.toLowerCase()}.png`, 156 | ) 157 | .setDescription( 158 | `The **${property}** of **${pool}** has changed by **${percentageChange.toFixed(2)}%** compared to ${minutesAgo === 1 ? "a minute ago" : `${minutesAgo} minutes ago`}.`, 159 | ) 160 | .setTimestamp(); 161 | 162 | await hook.send(embed); 163 | return { notified: true, redisKey }; 164 | }; 165 | 166 | export const processPoolJob = async (job: Job) => { 167 | const { asset, derived } = job.data; 168 | 169 | if (!asset) { 170 | throw new Error(`Missing required parameters: asset`); 171 | } 172 | 173 | return compareAndNotifyPoolChange(asset, derived, [5, 15, 30, 60], job); 174 | }; 175 | -------------------------------------------------------------------------------- /src/jobs/networkPrice.ts: -------------------------------------------------------------------------------- 1 | import { buildNotification, notifyLock } from "../notifications"; 2 | import { findClosestTimeKey, redis } from "../database/redis"; 3 | import { Network, fetchNetwork } from "../api/thorchain"; 4 | import { DEFAULT_COMPARE_TIMES } from "../utils"; 5 | import { Job } from "bullmq"; 6 | 7 | const NOTIFICATION_LOCK_DURATION = 4 * 60 * 60; // 4 hours 8 | 9 | const MINIMUM_PERCENTAGE_DIFFERENCE_RUNE_PRICE_IN_TOR = 5; 10 | const MINIMUM_PERCENTAGE_DIFFERENCE_TOR_PRICE_IN_RUNE = 5; 11 | 12 | interface PriceData { 13 | key: string; 14 | currentPrice: number; 15 | dataSource: string; 16 | variable: string; 17 | threshold: number; 18 | } 19 | 20 | interface PriceChangeDetail { 21 | key: string; 22 | currentPrice: number; 23 | historicalPrices: { 24 | [compareTime: number]: { 25 | historicalPrice: number; 26 | percentageDifference: number; 27 | }; 28 | }; 29 | } 30 | 31 | const compareAndNotifyPriceChange = async ( 32 | { key, currentPrice, dataSource, variable, threshold }: PriceData, 33 | currentTime: number, 34 | compareTimes: number[], 35 | job: Job, 36 | ): Promise => { 37 | const redisKey = `price:${key}`; 38 | const historicalPrices: PriceChangeDetail["historicalPrices"] = {}; 39 | 40 | for (const time of compareTimes) { 41 | const { key: timeKey, value } = await findClosestTimeKey( 42 | redisKey, 43 | currentTime - time * 60000, 44 | ); 45 | 46 | const historicalPrice = Number(value); 47 | 48 | if (!timeKey || isNaN(historicalPrice) || historicalPrice <= 0) { 49 | await job.log( 50 | `No valid historical price found for ${redisKey} at ${time} minutes ago.`, 51 | ); 52 | continue; 53 | } 54 | 55 | const diff = Math.abs(currentPrice - historicalPrice); 56 | const percentageDifference = (diff * 100) / historicalPrice; 57 | 58 | historicalPrices[time] = { 59 | historicalPrice, 60 | percentageDifference, 61 | }; 62 | 63 | if (percentageDifference >= threshold) { 64 | await job.log(`Significant price change detected for ${key}`); 65 | const notificationResult = await sendPriceChangeNotification( 66 | key, 67 | historicalPrice, 68 | currentPrice, 69 | percentageDifference, 70 | time, 71 | dataSource, 72 | variable, 73 | ); 74 | 75 | if (notificationResult.notified) { 76 | await job.log( 77 | `Notified: ${key} price changed by ${percentageDifference.toFixed(2)}% (${historicalPrice} -> ${currentPrice}) over the last ${time} minutes.`, 78 | ); 79 | } else { 80 | await job.log( 81 | `Notification lock for ${notificationResult.redisKey} already exists.`, 82 | ); 83 | } 84 | } 85 | 86 | const progress = Math.round(((time + 1) / compareTimes.length) * 100); 87 | await job.updateProgress(progress); 88 | } 89 | 90 | const ttl = 7200; // 2 hours 91 | const newRedisKey = `${redisKey}:time:${currentTime}`; 92 | await redis.set(newRedisKey, currentPrice.toString(), "EX", ttl); 93 | 94 | return { 95 | key, 96 | currentPrice, 97 | historicalPrices, 98 | }; 99 | }; 100 | 101 | const sendPriceChangeNotification = async ( 102 | asset: string, 103 | priceBefore: number, 104 | priceAfter: number, 105 | percentageChange: number, 106 | minutesAgo: number, 107 | dataSource: string, 108 | variable: string, 109 | ) => { 110 | const redisKey = `price_change:${asset}`; 111 | if (!(await notifyLock(redisKey, NOTIFICATION_LOCK_DURATION))) { 112 | return { notified: false, redisKey }; 113 | } 114 | 115 | const { hook, builder } = buildNotification(); 116 | 117 | const title = 118 | asset.trim() === "rune_price_in_tor" 119 | ? "RUNE Price in TOR Change" 120 | : "TOR Price in RUNE Change"; 121 | const description = 122 | asset.trim() === "rune_price_in_tor" 123 | ? "The price of **RUNE** denominated in **TOR**." 124 | : "The price of **TOR** denominated in **RUNE**."; 125 | const previousPrice = `${priceBefore.toFixed(asset.trim() === "rune_price_in_tor" ? 4 : 8)} ${asset.trim() === "rune_price_in_tor" ? "TOR" : "RUNE"}`; 126 | const currentPrice = `${priceAfter.toFixed(asset.trim() === "rune_price_in_tor" ? 4 : 8)} ${asset.trim() === "rune_price_in_tor" ? "TOR" : "RUNE"}`; 127 | const image = 128 | "https://github.com/thorchain/Resources/raw/refs/heads/master/Assets/Rune/RUNE-ICON-NOSHADOW@3x.png"; 129 | // const image = 130 | // asset.trim() === "rune_price_in_tor" 131 | // ? "https://static.thorswap.net/token-list/images/thor.rune.png" 132 | // : "https://static.thorswap.net/token-list/images/eth.vthor-0x815c23eca83261b6ec689b60cc4a58b54bc24d8d.png"; 133 | 134 | const changeText = `${priceAfter >= priceBefore ? "+" : ""}${(priceAfter - priceBefore).toFixed(asset.trim() === "rune_price_in_tor" ? 4 : 8)} ${asset.trim() === "rune_price_in_tor" ? "TOR" : "RUNE"} (${percentageChange.toFixed(2)}%)`; 135 | 136 | const embed = builder 137 | .setAuthor( 138 | "Network Price Change", 139 | "https://static-00.iconduck.com/assets.00/cellular-network-icon-2048x2048-fyfng5i8.png", 140 | ) 141 | .setTitle(`**${title}**`) 142 | .setURL(dataSource) 143 | .addField("Previous Price", previousPrice, true) 144 | .addField("Current Price", currentPrice, true) 145 | .addField("Change", changeText, true) 146 | .setColor("#FF0000") 147 | .setThumbnail(image) 148 | .setDescription( 149 | `${description} The price has changed by **${percentageChange.toFixed(2)}%** in the last ${ 150 | minutesAgo === 1 ? "minute" : `${minutesAgo} minutes` 151 | }.\n\n[View data on Thornode API:](${dataSource}) **${variable}**`, 152 | ) 153 | .setTimestamp(); 154 | 155 | await hook.send(embed); 156 | return { notified: true, redisKey }; 157 | }; 158 | 159 | export const processNetworkPriceJob = async ( 160 | job: Job, 161 | compareTimes = DEFAULT_COMPARE_TIMES, 162 | ): Promise => { 163 | const currentTime = Date.now(); 164 | const currentNetworkData: Network = await fetchNetwork(); 165 | 166 | const runePriceInTor = Number(currentNetworkData.rune_price_in_tor) / 1e8; 167 | const torPriceInRune = Number(currentNetworkData.tor_price_in_rune) / 1e8; 168 | 169 | if ( 170 | runePriceInTor <= 0 || 171 | torPriceInRune <= 0 || 172 | isNaN(runePriceInTor) || 173 | isNaN(torPriceInRune) 174 | ) { 175 | throw new Error("Invalid data from fetchNetwork"); 176 | } 177 | 178 | const runePriceChange = await compareAndNotifyPriceChange( 179 | { 180 | key: "rune_price_in_tor", 181 | currentPrice: runePriceInTor, 182 | dataSource: "https://thornode.ninerealms.com/thorchain/network", 183 | variable: "rune_price_in_tor", 184 | threshold: MINIMUM_PERCENTAGE_DIFFERENCE_RUNE_PRICE_IN_TOR, 185 | }, 186 | currentTime, 187 | compareTimes, 188 | job, 189 | ); 190 | 191 | return [runePriceChange]; 192 | 193 | // const torPriceChange = await compareAndNotifyPriceChange( 194 | // { 195 | // key: "tor_price_in_rune", 196 | // currentPrice: torPriceInRune, 197 | // dataSource: "https://thornode.ninerealms.com/thorchain/network", 198 | // variable: "tor_price_in_rune", 199 | // threshold: MINIMUM_PERCENTAGE_DIFFERENCE_TOR_PRICE_IN_RUNE, 200 | // }, 201 | // currentTime, 202 | // compareTimes, 203 | // job, 204 | // ); 205 | 206 | // return [runePriceChange, torPriceChange]; 207 | }; 208 | -------------------------------------------------------------------------------- /src/jobs/prices.ts: -------------------------------------------------------------------------------- 1 | import { buildNotification, notifyLock } from "../notifications"; 2 | import { findClosestTimeKey, redis } from "../database/redis"; 3 | import { 4 | calculatePriceInUSD, 5 | fetchAllPools, 6 | fetchDerivedPool, 7 | fetchDerivedPools, 8 | fetchRuneUSDPrice, 9 | } from "../api/thorchain"; 10 | import { DEFAULT_COMPARE_TIMES, formatNumberPrice } from "../utils"; 11 | import { Job } from "bullmq"; 12 | 13 | const NOTIFICATION_LOCK_DURATION = 4 * 60 * 60; // 4 hours 14 | const MINIMUM_PERCENTAGE_CHANGE = 10; 15 | 16 | interface PriceChangeDetail { 17 | asset: string; 18 | currentPrice: number; 19 | historicalPrices: Record< 20 | number, 21 | { historicalPrice: number; percentageDifference: number } 22 | >; 23 | } 24 | 25 | const compareAndNotifyPriceChange = async ( 26 | asset: string, 27 | priceInUSD: number, 28 | runePriceInUsd: number, 29 | redisKey: string, 30 | historicPrices: PriceChangeDetail["historicalPrices"], 31 | compareTimes: number[], 32 | isDerivedPool: boolean, 33 | job: Job, 34 | ): Promise => { 35 | const currentTime = Date.now(); 36 | 37 | for (const [index, time] of compareTimes.entries()) { 38 | const { key, value } = await findClosestTimeKey( 39 | redisKey, 40 | currentTime - time * 60000, 41 | ); 42 | 43 | const historicalPrice = Number(value); 44 | if (!key || isNaN(historicalPrice) || historicalPrice <= 0) { 45 | await job.log( 46 | `No valid historical price found for ${redisKey} at ${time} minutes ago.`, 47 | ); 48 | continue; 49 | } 50 | 51 | const percentageChange = 52 | Math.abs((priceInUSD - historicalPrice) / historicalPrice) * 100; 53 | 54 | historicPrices[time] = { 55 | historicalPrice, 56 | percentageDifference: percentageChange, 57 | }; 58 | 59 | if (percentageChange >= MINIMUM_PERCENTAGE_CHANGE) { 60 | await job.log(`Significant price change detected for ${asset}`); 61 | 62 | const notificationResult = await sendPriceChangeNotification( 63 | asset, 64 | historicalPrice, 65 | priceInUSD, 66 | percentageChange, 67 | time, 68 | isDerivedPool, 69 | ); 70 | 71 | if (notificationResult.notified) { 72 | await job.log( 73 | `Notified: Significant price change for ${asset}: ${percentageChange.toFixed(2)}% (${historicalPrice} -> ${priceInUSD}) over the last ${time} minutes.`, 74 | ); 75 | } else { 76 | await job.log( 77 | `Notification lock for ${notificationResult.redisKey} already exists.`, 78 | ); 79 | } 80 | } 81 | 82 | const progress = Math.round(((index + 1) / compareTimes.length) * 100); 83 | await job.updateProgress(progress); 84 | } 85 | 86 | const ttl = 7200; // 2 hours 87 | const newRedisKey = `${redisKey}:time:${currentTime}`; 88 | await redis.set(newRedisKey, priceInUSD.toString(), "EX", ttl); 89 | }; 90 | 91 | const sendPriceChangeNotification = async ( 92 | asset: string, 93 | priceBefore: number, 94 | priceNow: number, 95 | percentageChange: number, 96 | minutesAgo: number, 97 | isDerivedPool: boolean, 98 | ) => { 99 | const redisKey = `price_change:${asset}`; 100 | if (!(await notifyLock(redisKey, NOTIFICATION_LOCK_DURATION))) { 101 | return { notified: false, redisKey }; 102 | } 103 | 104 | const roundedToMinutesAgo = Math.round(minutesAgo); 105 | const { hook, builder } = buildNotification(); 106 | const severity = getSeverityLevel(percentageChange); 107 | 108 | const identifier = asset.split(".").pop()?.toLowerCase(); 109 | const secondaryIdentifier = asset.split(".")[1]?.toLowerCase(); 110 | 111 | const imageAsset = isDerivedPool 112 | ? `${secondaryIdentifier}.${secondaryIdentifier}` 113 | : `${identifier}.${identifier}`; 114 | 115 | const image = `https://static.thorswap.net/token-list/images/${imageAsset}.png`; 116 | const url = `https://viewblock.io/thorchain/pool/${asset}`; 117 | 118 | const absoluteDifference = Math.abs(priceNow - priceBefore); 119 | const relativeChange = getRelativeChange(priceBefore, priceNow); 120 | 121 | const title = `${asset} ${isDerivedPool ? "[Derived] " : ""} THORChain Price Change`; 122 | 123 | const embed = builder 124 | .setAuthor( 125 | "THORChain Price", 126 | "https://blog.mexc.com/wp-content/uploads/2022/09/1_KkoJRE6ICrE70mNegVeY_Q.png", 127 | ) 128 | .setTitle(title) 129 | .addField("Price Before (USD)", formatNumberPrice(priceBefore), true) 130 | .addField("Price Now (USD)", formatNumberPrice(priceNow), true) 131 | .addField("Percentage Change", `${percentageChange.toFixed(2)}%`, true) 132 | .addField( 133 | "Absolute Difference", 134 | formatNumberPrice(absoluteDifference), 135 | true, 136 | ) 137 | .addField("Relative Change", relativeChange, true) 138 | .addField("Severity", severity, true) 139 | .addField("THORChain Asset", `[View Pool](${url})`, true) 140 | .setColor(getSeverityColor(severity)) 141 | .setThumbnail(image) 142 | .setDescription( 143 | `A ${severity.toLowerCase()} price change has been detected for **${asset}** in the last ${ 144 | roundedToMinutesAgo === 1 ? "minute" : `${roundedToMinutesAgo} minutes` 145 | }.`, 146 | ) 147 | .setTimestamp(); 148 | 149 | await hook.send(embed); 150 | return { notified: true, redisKey }; 151 | }; 152 | 153 | export const processPriceJob = async ( 154 | job: Job, 155 | compareTimes: number[] = DEFAULT_COMPARE_TIMES, 156 | ): Promise => { 157 | const pools = await fetchAllPools(); 158 | const runePriceInUsd = await fetchRuneUSDPrice(pools); 159 | const priceChangeDetails: PriceChangeDetail[] = []; 160 | 161 | const thorTorPool = await fetchDerivedPool("THOR.TOR"); 162 | pools.push(thorTorPool); 163 | 164 | for (const pool of pools) { 165 | const priceInUSD = calculatePriceInUSD(pool, runePriceInUsd); 166 | if (priceInUSD === null) { 167 | await job.log(`Failed to calculate price for ${pool.asset}`); 168 | continue; 169 | } 170 | 171 | const redisKey = `price:${pool.asset}`; 172 | 173 | const historicPrices: PriceChangeDetail["historicalPrices"] = {}; 174 | 175 | const isDerived = pool.asset === "THOR.TOR"; 176 | 177 | await compareAndNotifyPriceChange( 178 | pool.asset, 179 | priceInUSD, 180 | runePriceInUsd, 181 | redisKey, 182 | historicPrices, 183 | compareTimes, 184 | // derivedPools.some((p) => p.asset === pool.asset), 185 | isDerived, 186 | job, 187 | ); 188 | 189 | priceChangeDetails.push({ 190 | asset: pool.asset, 191 | currentPrice: priceInUSD, 192 | historicalPrices: historicPrices, 193 | }); 194 | } 195 | 196 | return priceChangeDetails; 197 | }; 198 | 199 | const getSeverityLevel = (percentageChange: number): string => { 200 | if (percentageChange >= 50) return "Critical"; 201 | if (percentageChange >= 20) return "High"; 202 | if (percentageChange >= 10) return "Medium"; 203 | return "Low"; 204 | }; 205 | 206 | const getSeverityColor = (severity: string): number => { 207 | switch (severity) { 208 | case "Critical": 209 | return 0xff0000; // Red 210 | case "High": 211 | return 0xffa500; // Orange 212 | case "Medium": 213 | return 0xffff00; // Yellow 214 | case "Low": 215 | return 0x00ff00; // Green 216 | default: 217 | return 0x7289da; // Discord Blurple (default color) 218 | } 219 | }; 220 | 221 | const getRelativeChange = (priceBefore: number, priceNow: number): string => { 222 | const relativeChange = ((priceNow - priceBefore) / priceBefore) * 100; 223 | return `${relativeChange >= 0 ? "Higher" : "Lower"} by ${Math.abs(relativeChange).toFixed(2)}%`; 224 | }; 225 | -------------------------------------------------------------------------------- /src/jobs/thorchainBalance.ts: -------------------------------------------------------------------------------- 1 | import { buildNotification, notifyLock } from "../notifications"; 2 | import { findClosestTimeKey, redis } from "../database/redis"; 3 | import { fetchBalances } from "../api/thorchain"; 4 | import { getAveragePriceByAsset } from "../services/prices"; 5 | import { 6 | DEFAULT_COMPARE_TIMES, 7 | formatNumber, 8 | formatNumberPrice, 9 | } from "../utils"; 10 | import { Job } from "bullmq"; 11 | 12 | const NOTIFICATION_LOCK_DURATION = 4 * 60 * 60; // 4 hours 13 | 14 | export const wallets: Wallet[] = [ 15 | { 16 | address: "thor1g98cy3n9mmjrpn0sxmn63lztelera37n8n67c0", 17 | name: "Pool Module", 18 | percentage: 20, 19 | }, 20 | { 21 | address: "thor1dheycdevq39qlkxs2a6wuuzyn4aqxhve4qxtxt", 22 | name: "Reserve Module", 23 | percentage: 5, 24 | }, 25 | { 26 | address: "thor17gw75axcnr8747pkanye45pnrwk7p9c3cqncsv", 27 | name: "Bond Module", 28 | percentage: 5, 29 | }, 30 | { 31 | address: "thor1egxvam70a86jafa8gcg3kqfmfax3s0m2g3m754", 32 | name: "Treasury: LP", 33 | percentage: 5, 34 | }, 35 | { 36 | address: "thor14n2q7tpemxcha8zc26j0g5pksx4x3a9xw9ryq9", 37 | name: "Treasury: 2", 38 | percentage: 5, 39 | }, 40 | { 41 | address: "thor1qd4my7934h2sn5ag5eaqsde39va4ex2asz3yv5", 42 | name: "Treasury: 1", 43 | percentage: 5, 44 | }, 45 | { 46 | address: "thor1wfe7hsuvup27lx04p5al4zlcnx6elsnyft7dzm", 47 | name: "Treasury: LP 2", 48 | percentage: 5, 49 | }, 50 | { 51 | address: "thor10qh5272ktq4wes8ex343ky9rsuehcypddjh08k", 52 | name: "Treasury: Vultisig", 53 | percentage: 5, 54 | }, 55 | ]; 56 | 57 | export interface Wallet { 58 | address: string; 59 | name: string; 60 | percentage: number; 61 | } 62 | 63 | interface BalanceChangeDetail { 64 | denom: string; 65 | currentAmount: number; 66 | historicalAmounts: { 67 | [compareTime: number]: { 68 | historicalAmount: number; 69 | percentageDifference: number; 70 | diffUSD?: number; 71 | }; 72 | }; 73 | } 74 | 75 | const compareAndNotifyWalletChange = async ( 76 | wallet: Wallet, 77 | compareTimes: number[], 78 | job: Job, 79 | ): Promise => { 80 | const { address, name, percentage } = wallet; 81 | const currentTime = Date.now(); 82 | const balances = await fetchBalances(address); 83 | 84 | const balanceChangeDetails: BalanceChangeDetail[] = []; 85 | 86 | for (const balance of balances) { 87 | const currentAmount = +balance.amount; 88 | const redisKey = `wallet:${address}:${balance.denom}`; 89 | const historicalAmounts: BalanceChangeDetail["historicalAmounts"] = {}; 90 | 91 | for (const time of compareTimes) { 92 | try { 93 | const { key, value } = await findClosestTimeKey( 94 | redisKey, 95 | currentTime - time * 60000, 96 | ); 97 | const historicalAmount = Number(value); 98 | 99 | if (!key || isNaN(historicalAmount) || historicalAmount <= 0) { 100 | await job.log( 101 | `No valid historical balance found for ${redisKey} at ${time} minutes ago.`, 102 | ); 103 | continue; 104 | } 105 | 106 | const diff = currentAmount - historicalAmount; 107 | const percentageDifference = (diff * 100) / historicalAmount; 108 | 109 | const price = await getAveragePriceByAsset(balance.denom); 110 | let diffUSD: number | undefined; 111 | 112 | if (price) { 113 | diffUSD = diff * price; 114 | historicalAmounts[time] = { 115 | historicalAmount, 116 | percentageDifference, 117 | diffUSD, 118 | }; 119 | 120 | if (Math.abs(percentageDifference) >= percentage) { 121 | if (Math.abs(diffUSD) < 10000) { 122 | await job.log( 123 | `[BALANCE] Skipping ${balance.denom} due to too little USD change (${diffUSD.toFixed(2)}).`, 124 | ); 125 | continue; 126 | } 127 | 128 | await job.log( 129 | `Significant change detected for ${balance.denom} in wallet ${name}`, 130 | ); 131 | const notificationResult = await sendBalanceChangeNotification( 132 | balance.denom, 133 | address, 134 | name, 135 | historicalAmount, 136 | historicalAmount * price, 137 | currentAmount, 138 | currentAmount * price, 139 | percentageDifference, 140 | time, 141 | ); 142 | 143 | if (notificationResult.notified) { 144 | await job.log( 145 | `Notified: Significant change in ${balance.denom} for ${address} (${name}) at ${time} minutes ago: ${percentageDifference.toFixed(2)}%`, 146 | ); 147 | } else { 148 | await job.log( 149 | `Notification lock for ${notificationResult.redisKey} already exists.`, 150 | ); 151 | } 152 | } 153 | } else { 154 | historicalAmounts[time] = { historicalAmount, percentageDifference }; 155 | await job.log( 156 | `No price found for denom: ${balance.denom}. Unable to calculate USD diffs.`, 157 | ); 158 | } 159 | } catch (err) { 160 | await job.log( 161 | `Error processing ${balance.denom} for ${time} minutes ago: ${err}`, 162 | ); 163 | } 164 | } 165 | 166 | await redis.set( 167 | `${redisKey}:time:${currentTime}`, 168 | currentAmount.toString(), 169 | "EX", 170 | 7200, 171 | ); // 2 hours expiry 172 | 173 | balanceChangeDetails.push({ 174 | denom: balance.denom, 175 | currentAmount, 176 | historicalAmounts, 177 | }); 178 | } 179 | 180 | return balanceChangeDetails; 181 | }; 182 | 183 | const sendBalanceChangeNotification = async ( 184 | denom: string, 185 | address: string, 186 | nickname: string, 187 | amountBefore: number, 188 | amountBeforeUSD: number | null, 189 | amountAfter: number, 190 | amountAfterUSD: number | null, 191 | percentageChange: number, 192 | minutesAgo: number, 193 | ) => { 194 | const redisKey = `balance_change:${address}:${denom}`; 195 | if (!(await notifyLock(redisKey, NOTIFICATION_LOCK_DURATION))) { 196 | return { notified: false, redisKey }; 197 | } 198 | 199 | const { hook, builder } = buildNotification(); 200 | const identifier = denom.toLowerCase().replace("/", "."); 201 | const image = `https://static.thorswap.net/token-list/images/${identifier}.png`; 202 | const url = `https://viewblock.io/thorchain/address/${address}`; 203 | 204 | const changeAmount = amountAfter - amountBefore; 205 | const changeUSD = 206 | amountAfterUSD !== null && amountBeforeUSD !== null 207 | ? amountAfterUSD - amountBeforeUSD 208 | : null; 209 | 210 | const embed = builder 211 | .setAuthor( 212 | "Balance Change", 213 | "https://icons.veryicon.com/png/o/business/business-style-icon/wallet-62.png", 214 | ) 215 | .setTitle( 216 | `${nickname}: ${denom} ${percentageChange.toFixed(0)}% Balance Change`, 217 | ) 218 | .setURL(url) 219 | .addField( 220 | "Before", 221 | `**${formatNumber(amountBefore)}** (${amountBeforeUSD !== null ? formatNumberPrice(amountBeforeUSD) : "N/A"})`, 222 | true, 223 | ) 224 | .addField( 225 | "Now", 226 | `**${formatNumber(amountAfter)}** (${amountAfterUSD !== null ? formatNumberPrice(amountAfterUSD) : "N/A"})`, 227 | true, 228 | ) 229 | .addField( 230 | "Change", 231 | `**${changeAmount < 0 ? "" : "+"}${formatNumber(changeAmount)}** (${changeUSD !== null ? `${changeUSD > 0 ? "+" : ""}${formatNumberPrice(changeUSD)}` : "N/A"})`, 232 | true, 233 | ) 234 | .setColor("#FF0000") 235 | .setThumbnail(image) 236 | .setDescription( 237 | `The balance of **${denom}** in wallet **${nickname}** has changed by **${percentageChange.toFixed(2)}%** over the past ${minutesAgo === 1 ? "minute" : `${minutesAgo} minutes`} ago.`, 238 | ) 239 | .setTimestamp(); 240 | 241 | await hook.send(embed); 242 | return { notified: true, redisKey }; 243 | }; 244 | 245 | export const processThorchainBalanceJob = async (job: Job) => { 246 | const { wallet } = job.data; 247 | 248 | if ( 249 | !wallet || 250 | !wallet.address || 251 | !wallet.name || 252 | wallet.percentage === undefined 253 | ) { 254 | throw new Error("Job data is missing wallet information."); 255 | } 256 | 257 | return compareAndNotifyWalletChange(wallet, DEFAULT_COMPARE_TIMES, job); 258 | }; 259 | -------------------------------------------------------------------------------- /src/jobs/coingeckoComparison.ts: -------------------------------------------------------------------------------- 1 | import { buildNotification, notifyLock } from "../notifications"; 2 | import { 3 | fetchRuneUSDPrice, 4 | calculatePriceInUSD, 5 | fetchPools, 6 | } from "../api/thorchain"; 7 | import { formatNumberPrice } from "../utils"; 8 | import { 9 | fetchCoinGeckoPrices, 10 | fetchCoinGeckoList, 11 | mapThorchainAssetToCoinGeckoId, 12 | } from "../api/coingecko"; 13 | import { Job } from "bullmq"; 14 | import { Pool } from "../api/thorchain/schemas"; 15 | import { CoinGeckoCoinItem, CoinGeckoPrice } from "../api/coingecko/schemas"; 16 | 17 | const SIGNIFICANT_PRICE_DIFF_THRESHOLD = 10; 18 | const EXTREME_PRICE_DIFF_THRESHOLD = 1000; 19 | const ENABLE_EXTREME_PRICE_DETECTION = true; 20 | const NOTIFICATION_LOCK_DURATION = 24 * 60 * 60; // 24 hours 21 | 22 | const ASSETS_TO_SKIP = new Set([ 23 | "ETH.VTHOR-0X815C23ECA83261B6EC689B60CC4A58B54BC24D8D", 24 | ]); 25 | const ASSET_MULTIPLIERS = new Map([ 26 | ["ETH.THOR-0XA5F2211B9B8170F694421F2046281775E8468044", 1.5], 27 | ["ETH.TGT-0X108A850856DB3F85D0269A2693D896B394C80325", 2], 28 | ["ETH.XRUNE-0X69FA0FEE221AD11012BAB0FDB45D444D3D2CE71C", 1.5], 29 | ]); 30 | 31 | const THRESHOLDS = { 32 | nonSignificant: 10, 33 | significant: 20, 34 | extreme: 90, 35 | }; 36 | 37 | interface PriceComparisonDetail { 38 | asset: string; 39 | thorchainPriceInUSD: number; 40 | coingeckoPriceInUSD: number; 41 | percentageDifference: number; 42 | } 43 | 44 | const createCoinGeckoIdMap = async ( 45 | pools: Pool[], 46 | coinGeckoList: CoinGeckoCoinItem[], 47 | job: Job, 48 | ): Promise> => { 49 | const coinGeckoIdMap = new Map(); 50 | for (const pool of pools) { 51 | const coinGeckoId = mapThorchainAssetToCoinGeckoId( 52 | pool.asset, 53 | coinGeckoList, 54 | ); 55 | if (coinGeckoId) { 56 | coinGeckoIdMap.set(pool.asset, coinGeckoId); 57 | } else if (!ASSETS_TO_SKIP.has(pool.asset)) { 58 | await job.log(`No CoinGecko ID found for asset: ${pool.asset}`); 59 | } 60 | } 61 | return coinGeckoIdMap; 62 | }; 63 | 64 | const calculatePercentageDifference = ( 65 | price1: number, 66 | price2: number, 67 | ): number => { 68 | const priceDifference = Math.abs(price1 - price2); 69 | const averagePrice = (price1 + price2) / 2; 70 | return (priceDifference / averagePrice) * 100; 71 | }; 72 | 73 | const getSeverityLevel = (percentageDifference: number): string => { 74 | if (percentageDifference >= THRESHOLDS.extreme) return "Critical"; 75 | if (percentageDifference >= THRESHOLDS.significant) return "High"; 76 | if (percentageDifference >= THRESHOLDS.nonSignificant) return "Medium"; 77 | return "Low"; 78 | }; 79 | 80 | const sendNotification = async ( 81 | asset: string, 82 | thorchainPrice: number, 83 | coingeckoPrice: number, 84 | percentageDifference: number, 85 | doNotAlert: boolean, 86 | coinGeckoId: string | undefined, 87 | multiplier: number, 88 | severity: string, 89 | ) => { 90 | if (doNotAlert) return { notified: false }; 91 | 92 | const redisKey = `price_difference:${asset}:${severity}`; 93 | if (!(await notifyLock(redisKey, NOTIFICATION_LOCK_DURATION))) { 94 | return { notified: false, redisKey }; 95 | } 96 | 97 | const { hook, builder } = buildNotification(); 98 | const identifier = asset.toLowerCase().replace("/", "."); 99 | const image = `https://static.thorswap.net/token-list/images/${identifier}.png`; 100 | 101 | const embed = builder 102 | .setAuthor( 103 | "CoinGecko vs THORChain Price", 104 | "https://support.coingecko.com/hc/article_attachments/4499575478169.png", 105 | ) 106 | .setTitle(`${asset} CoinGecko Price Discrepancy`) 107 | .addField("THORChain Price", formatNumberPrice(thorchainPrice), true) 108 | .addField("CoinGecko Price", formatNumberPrice(coingeckoPrice), true) 109 | .addField("Difference", `${percentageDifference.toFixed(2)}%`, true) 110 | .addField( 111 | "THORChain vs CoinGecko", 112 | (thorchainPrice > coingeckoPrice ? "Higher by" : "Lower by") + 113 | ` ${Math.abs(percentageDifference).toFixed(2)}%`, 114 | true, 115 | ) 116 | .addField("Severity", severity, true) 117 | .addField( 118 | "THORChain Asset", 119 | `[View Pool](https://viewblock.io/thorchain/pool/${asset})`, 120 | true, 121 | ) 122 | .addField( 123 | "CoinGecko ID", 124 | coinGeckoId 125 | ? `[View on CoinGecko](https://www.coingecko.com/en/coins/${coinGeckoId})` 126 | : "N/A", 127 | true, 128 | ) 129 | .setColor( 130 | severity === "Critical" 131 | ? 0xff0000 132 | : severity === "High" 133 | ? 0xffa500 134 | : severity === "Medium" 135 | ? 0xffff00 136 | : 0x00ff00, 137 | ) 138 | .setThumbnail(image) 139 | .setDescription( 140 | `A ${severity.toLowerCase()} price discrepancy has been detected for **${identifier.toUpperCase()}** between THORChain and CoinGecko.${multiplier !== 1 ? ` (Alert threshold multiplier: ${multiplier}x)` : ""}`, 141 | ) 142 | .setTimestamp(); 143 | 144 | await hook.send(embed); 145 | return { notified: true, redisKey }; 146 | }; 147 | 148 | const comparePoolPrices = async ( 149 | pool: Pool, 150 | runePriceInUsd: number, 151 | coinGeckoIdMap: Map, 152 | coinGeckoPrices: CoinGeckoPrice, 153 | doNotAlert: boolean, 154 | job: Job, 155 | ): Promise => { 156 | if (ASSETS_TO_SKIP.has(pool.asset)) return null; 157 | 158 | const thorchainPriceInUSD = calculatePriceInUSD(pool, runePriceInUsd); 159 | if (thorchainPriceInUSD === null) { 160 | await job.log(`Failed to calculate THORChain price for ${pool.asset}`); 161 | return null; 162 | } 163 | 164 | const coinGeckoId = coinGeckoIdMap.get(pool.asset); 165 | const coingeckoPriceInUSD = coinGeckoId 166 | ? coinGeckoPrices[coinGeckoId]?.usd 167 | : null; 168 | 169 | if (coingeckoPriceInUSD === null) { 170 | await job.log(`No CoinGecko price found for ${pool.asset}`); 171 | return null; 172 | } 173 | 174 | const percentageDifference = calculatePercentageDifference( 175 | thorchainPriceInUSD, 176 | coingeckoPriceInUSD, 177 | ); 178 | const multiplier = ASSET_MULTIPLIERS.get(pool.asset) || 1; 179 | const adjustedThreshold = SIGNIFICANT_PRICE_DIFF_THRESHOLD * multiplier; 180 | 181 | const severity = getSeverityLevel(percentageDifference); 182 | 183 | if (percentageDifference >= adjustedThreshold) { 184 | await job.log(`Significant price difference detected for ${pool.asset}`); 185 | const notificationResult = await sendNotification( 186 | pool.asset, 187 | thorchainPriceInUSD, 188 | coingeckoPriceInUSD, 189 | percentageDifference, 190 | doNotAlert, 191 | coinGeckoId, 192 | multiplier, 193 | severity, 194 | ); 195 | 196 | if (notificationResult.notified) { 197 | await job.log( 198 | `Notified: Significant price difference for ${pool.asset}: ${percentageDifference.toFixed(2)}%`, 199 | ); 200 | } else { 201 | await job.log( 202 | `Notification lock for ${notificationResult.redisKey} already exists.`, 203 | ); 204 | } 205 | } 206 | 207 | if ( 208 | ENABLE_EXTREME_PRICE_DETECTION && 209 | percentageDifference >= EXTREME_PRICE_DIFF_THRESHOLD 210 | ) { 211 | await job.log( 212 | `EXTREME PRICE DIFFERENCE DETECTED for ${pool.asset}: ${percentageDifference.toFixed(2)}%. Manual investigation required.`, 213 | ); 214 | } 215 | 216 | return { 217 | asset: pool.asset, 218 | thorchainPriceInUSD, 219 | coingeckoPriceInUSD, 220 | percentageDifference, 221 | }; 222 | }; 223 | 224 | export const processCoingeckoComparisonJob = async ( 225 | job: Job, 226 | ): Promise => { 227 | const { doNotAlert } = job.data; 228 | const pools = await fetchPools(); 229 | const runePriceInUSD = await fetchRuneUSDPrice(pools); 230 | 231 | const uniquePools = Array.from(new Set(pools.map((p) => p.asset))).map( 232 | (asset) => pools.find((p) => p.asset === asset)!, 233 | ) as Pool[]; 234 | 235 | const coinGeckoList = await fetchCoinGeckoList(); 236 | 237 | const coinGeckoIdMap = await createCoinGeckoIdMap( 238 | uniquePools, 239 | coinGeckoList, 240 | job, 241 | ); 242 | 243 | const coinGeckoPrices = await fetchCoinGeckoPrices( 244 | Array.from(coinGeckoIdMap.values()), 245 | ); 246 | 247 | const priceComparisonDetails: PriceComparisonDetail[] = []; 248 | 249 | for (const [index, pool] of uniquePools.entries()) { 250 | try { 251 | const result = await comparePoolPrices( 252 | pool, 253 | runePriceInUSD, 254 | coinGeckoIdMap, 255 | coinGeckoPrices, 256 | doNotAlert, 257 | job, 258 | ); 259 | if (result) { 260 | priceComparisonDetails.push(result); 261 | } 262 | } catch (error) { 263 | await job.log(`Error comparing price for pool ${pool.asset}: ${error}`); 264 | } 265 | 266 | // Update job progress 267 | const progress = Math.round(((index + 1) / uniquePools.length) * 100); 268 | await job.updateProgress(progress); 269 | } 270 | 271 | return priceComparisonDetails; 272 | }; 273 | --------------------------------------------------------------------------------