diff --git a/services/txnBot/src/service.ts b/services/txnBot/src/service.ts index 32efa63..a644c0a 100644 --- a/services/txnBot/src/service.ts +++ b/services/txnBot/src/service.ts @@ -1,20 +1,14 @@ -import path from 'path'; -import { fileURLToPath } from 'url'; -import dotenv from 'dotenv'; -import { ethers, Contract, Wallet, JsonRpcProvider, TransactionResponse } from 'ethers'; -import express, { Request, Response, NextFunction } from 'express'; +import express, { NextFunction, Request, Response } from 'express'; +import { ethers } from 'ethers'; import { decodePositionId } from 'kraiken-lib/ids'; import { isPositionDelinquent } from 'kraiken-lib/staking'; +import { pathToFileURL } from 'url'; +import { BotConfigService } from './services/BotConfigService.js'; +import { BlockchainService } from './services/BlockchainService.js'; +import { GraphQLService } from './services/GraphQLService.js'; import { logger } from './logger.js'; -import { hasRecenterAccess, readRecenterAccess, type RecenterAccessReader } from './recenterAccess.js'; -import { Position, EnvConfig, RecenterAccessStatus, RecenterEligibility, RecenterResult, GraphQLResponse } from './types.js'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const dotenvPath = process.env.TXN_BOT_ENV_FILE ? path.resolve(process.env.TXN_BOT_ENV_FILE) : path.resolve(__dirname, '..', '.env'); - -dotenv.config({ path: dotenvPath }); +import { hasRecenterAccess, readRecenterAccess } from './recenterAccess.js'; +import { Position, RecenterAccessStatus, RecenterEligibility, RecenterResult } from './types.js'; const ACTIVE_POSITIONS_QUERY = ` query ActivePositions { @@ -30,91 +24,29 @@ const ACTIVE_POSITIONS_QUERY = ` } `; -const PROVIDER_URL = process.env.PROVIDER_URL; -const PRIVATE_KEY = process.env.PRIVATE_KEY; -const LM_CONTRACT_ADDRESS = process.env.LM_CONTRACT_ADDRESS; -const STAKE_CONTRACT_ADDRESS = process.env.STAKE_CONTRACT_ADDRESS; -const GRAPHQL_ENDPOINT = process.env.GRAPHQL_ENDPOINT; -const ENVIRONMENT = process.env.ENVIRONMENT || 'UNSPECIFIED'; +const ZERO_ADDRESS = ethers.ZeroAddress; -const requiredEnv: Partial = { - PROVIDER_URL, - PRIVATE_KEY, - LM_CONTRACT_ADDRESS, - STAKE_CONTRACT_ADDRESS, - GRAPHQL_ENDPOINT, -}; - -for (const [key, value] of Object.entries(requiredEnv)) { - if (!value) { - throw new Error(`Missing required environment variable: ${key}`); - } +export interface TxnBotDependencies { + configService: BotConfigService; + blockchainService: BlockchainService; + graphQLService: GraphQLService; } -const LM_ABI = [ - { type: 'function', name: 'recenter', inputs: [], outputs: [], stateMutability: 'nonpayable' }, - { type: 'function', name: 'feeDestination', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' }, - { type: 'function', name: 'recenterAccess', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' }, -]; -const STAKE_ABI = [ - { - inputs: [{ internalType: 'uint256', name: 'positionId', type: 'uint256' }], - name: 'payTax', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, -]; - -const provider: JsonRpcProvider = new ethers.JsonRpcProvider(PROVIDER_URL); - -const wallet: Wallet = new ethers.Wallet(PRIVATE_KEY as string, provider); -const liquidityManager: Contract = new ethers.Contract(LM_CONTRACT_ADDRESS as string, LM_ABI, wallet); -const stakeContract: Contract = new ethers.Contract(STAKE_CONTRACT_ADDRESS as string, STAKE_ABI, wallet); -const recenterAccessReader: RecenterAccessReader = { - recenterAccess: async (): Promise => { - const method = liquidityManager.getFunction('recenterAccess'); - return (await method()) as string; - }, -}; -const walletAddress: string = ethers.getAddress(wallet.address); -const ZERO_ADDRESS: string = ethers.ZeroAddress; - -const startTime = new Date(); -let lastRecenterTime: Date | null = null; -let lastLiquidationTime: Date | null = null; -let lastRecenterTx: string | null = null; -let lastRecenterAccessStatus: RecenterAccessStatus | null = null; -let lastRecenterEligibility: RecenterEligibility = { - checkedAtMs: 0, - canRecenter: false, - reason: null, - error: null, -}; - -async function fetchActivePositions(): Promise { - const response = await fetch(GRAPHQL_ENDPOINT as string, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query: ACTIVE_POSITIONS_QUERY }), - }); - - if (!response.ok) { - throw new Error(`GraphQL request failed with status ${response.status}`); - } - - const payload = (await response.json()) as GraphQLResponse; - if (payload.errors && payload.errors.length > 0) { - const messages = payload.errors.map(error => error.message).join('; '); - throw new Error(`GraphQL responded with errors: ${messages}`); - } - - return payload.data?.positionss?.items ?? []; +export interface TxnBotInstance { + app: express.Application; + start: () => Promise; + getRecenterAccessStatus: (forceRefresh?: boolean) => Promise; + evaluateRecenterOpportunity: () => Promise; + attemptRecenter: () => Promise; } -async function checkFunds(): Promise { - const balance = await provider.getBalance(wallet.address); - return ethers.formatEther(balance); +function resolvePort(value: string): number | string { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return 3000; + } + const parsed = Number.parseInt(trimmed, 10); + return Number.isNaN(parsed) || parsed.toString() !== trimmed ? value : parsed; } function extractRevertReason(error: unknown): string | null { @@ -143,126 +75,6 @@ function extractRevertReason(error: unknown): string | null { return null; } -async function getRecenterAccessStatus(forceRefresh = false): Promise { - const now = Date.now(); - if ( - !forceRefresh && - lastRecenterAccessStatus && - lastRecenterAccessStatus.checkedAtMs && - now - lastRecenterAccessStatus.checkedAtMs < 30000 - ) { - return lastRecenterAccessStatus; - } - - let recenterAddress: string | null = null; - let hasAccess: boolean | null = null; - let slotHex: string | null = null; - let errorMessage: string | null = null; - - try { - const address = await readRecenterAccess(recenterAccessReader, ZERO_ADDRESS); - recenterAddress = address; - hasAccess = hasRecenterAccess(address, walletAddress, ZERO_ADDRESS); - slotHex = 'recenterAccess()'; - } catch (error) { - const err = error as { shortMessage?: string; message?: string }; - errorMessage = err?.shortMessage || err?.message || 'unknown error'; - recenterAddress = null; - } - - lastRecenterAccessStatus = { - hasAccess, - recenterAccessAddress: recenterAddress, - slot: slotHex, - checkedAtMs: now, - error: errorMessage, - }; - - return lastRecenterAccessStatus; -} - -async function evaluateRecenterOpportunity(): Promise { - const now = Date.now(); - const accessStatus = await getRecenterAccessStatus(true); - - if (accessStatus.error && accessStatus.hasAccess === null) { - lastRecenterEligibility = { - checkedAtMs: now, - canRecenter: false, - reason: 'Failed to determine recenter access.', - error: accessStatus.error, - }; - return lastRecenterEligibility; - } - - if (accessStatus.hasAccess === false) { - lastRecenterEligibility = { - checkedAtMs: now, - canRecenter: false, - reason: 'txnBot is not the authorized recenter caller.', - error: null, - }; - return lastRecenterEligibility; - } - - try { - await liquidityManager.recenter.estimateGas(); - lastRecenterEligibility = { - checkedAtMs: now, - canRecenter: true, - reason: null, - error: null, - }; - } catch (error) { - const err = error as { shortMessage?: string; message?: string }; - lastRecenterEligibility = { - checkedAtMs: now, - canRecenter: false, - reason: extractRevertReason(error) || 'recenter not currently executable.', - error: err?.shortMessage || err?.message || null, - }; - } - - return lastRecenterEligibility; -} - -async function attemptRecenter(): Promise { - const eligibility = await evaluateRecenterOpportunity(); - if (!eligibility.canRecenter) { - return { - executed: false, - message: eligibility.reason || 'Liquidity manager denied recenter.', - eligibility, - }; - } - - const tx: TransactionResponse = (await liquidityManager.recenter()) as TransactionResponse; - lastRecenterTx = tx.hash; - await tx.wait(); - lastRecenterTime = new Date(); - return { - executed: true, - txHash: tx.hash, - message: 'recenter transaction submitted', - eligibility: lastRecenterEligibility, - }; -} - -async function checkPosition(position: Position): Promise { - const taxRate = Number(position.taxRate); - const lastTaxTime = Number(position.lastTaxTime); - if (isPositionDelinquent(lastTaxTime, taxRate)) { - const positionId = decodePositionId(position.id); - logger.info(`Calling payTax on ${positionId}`); - const tx: TransactionResponse = (await stakeContract.payTax(positionId)) as TransactionResponse; - await tx.wait(); - lastLiquidationTime = new Date(); - return 1; - } else { - return 0; - } -} - function formatDuration(ms: number): string { let seconds = Math.floor(ms / 1000); let minutes = Math.floor(seconds / 60); @@ -276,113 +88,289 @@ function formatDuration(ms: number): string { return `${days} days, ${hours} hours, ${minutes} minutes`; } -async function liquidityLoop(): Promise { - try { - const result = await attemptRecenter(); - if (result.executed) { - logger.info(`recenter called successfully. tx=${result.txHash}`); - } else { - const reason = result.message ? `Reason: ${result.message}` : 'Reason: unavailable'; - logger.info(`Recenter skipped. ${reason} - ${new Date().toISOString()}`); - } - } catch (error) { - logger.error('Error in liquidity loop', { error }); +export function createTxnBot(dependencies: TxnBotDependencies): TxnBotInstance { + const { configService, blockchainService, graphQLService } = dependencies; + const envConfig = configService.getConfig(); + const recenterAccessReader = blockchainService.getRecenterAccessReader(); + const walletAddress = blockchainService.getWalletAddress(); + + const startTime = new Date(); + let lastRecenterTime: Date | null = null; + let lastLiquidationTime: Date | null = null; + let lastRecenterTx: string | null = null; + let lastRecenterAccessStatus: RecenterAccessStatus | null = null; + let lastRecenterEligibility: RecenterEligibility = { + checkedAtMs: 0, + canRecenter: false, + reason: null, + error: null, + }; + + async function fetchActivePositions(): Promise { + return graphQLService.fetchActivePositions(ACTIVE_POSITIONS_QUERY); } -} -async function liquidationLoop(): Promise { - let counter = 0; - try { - const positions = await fetchActivePositions(); - for (const position of positions) { - counter = counter + (await checkPosition(position)); - } - if (counter === 0) { - logger.info(`No tax can be claimed at the moment. - ${new Date().toISOString()}`); - } - } catch (error) { - logger.error('Error in liquidation loop', { error }); + async function checkFunds(): Promise { + return blockchainService.checkFunds(); } -} -async function main(): Promise { - logger.info(`txnBot service started for environment ${ENVIRONMENT}`); + async function getRecenterAccessStatus(forceRefresh = false): Promise { + const now = Date.now(); + if ( + !forceRefresh && + lastRecenterAccessStatus && + lastRecenterAccessStatus.checkedAtMs && + now - lastRecenterAccessStatus.checkedAtMs < 30000 + ) { + return lastRecenterAccessStatus; + } - await liquidityLoop(); - await liquidationLoop(); - setInterval(liquidityLoop, 3 * 60000); // 3 minute - setInterval(liquidationLoop, 20 * 60000); // 20 minutes -} + let recenterAddress: string | null = null; + let hasAccess: boolean | null = null; + let slotHex: string | null = null; + let errorMessage: string | null = null; -main().catch(async error => { - logger.error('Fatal error', { error }); -}); + try { + const address = await readRecenterAccess(recenterAccessReader, ZERO_ADDRESS); + recenterAddress = address; + hasAccess = hasRecenterAccess(address, walletAddress, ZERO_ADDRESS); + slotHex = 'recenterAccess()'; + } catch (error) { + const err = error as { shortMessage?: string; message?: string }; + errorMessage = err?.shortMessage || err?.message || 'unknown error'; + recenterAddress = null; + } -const app = express(); -const PORT = process.env.PORT || 3000; - -app.use((req: Request, res: Response, next: NextFunction) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - if (req.method === 'OPTIONS') { - return res.sendStatus(204); - } - return next(); -}); - -app.get('/status', async (_req: Request, res: Response) => { - try { - const balance = await checkFunds(); - const uptime = formatDuration(new Date().getTime() - startTime.getTime()); - const recenterAccessStatus = await getRecenterAccessStatus(); - const recenterEligibility = lastRecenterEligibility; - const status = { - balance: `${balance} ETH`, - uptime: uptime, - lastRecenterTime: lastRecenterTime ? lastRecenterTime.toISOString() : 'Never', - lastLiquidationTime: lastLiquidationTime ? lastLiquidationTime.toISOString() : 'Never', - lastRecenterTx, - recenterAccess: { - hasAccess: recenterAccessStatus?.hasAccess ?? null, - grantedTo: recenterAccessStatus?.recenterAccessAddress ?? null, - slot: recenterAccessStatus?.slot ?? null, - checkedAt: recenterAccessStatus?.checkedAtMs ? new Date(recenterAccessStatus.checkedAtMs).toISOString() : null, - error: recenterAccessStatus?.error ?? null, - }, - recenterEligibility: { - canRecenter: recenterEligibility?.canRecenter ?? null, - reason: recenterEligibility?.reason ?? null, - checkedAt: recenterEligibility?.checkedAtMs ? new Date(recenterEligibility.checkedAtMs).toISOString() : null, - error: recenterEligibility?.error ?? null, - }, + lastRecenterAccessStatus = { + hasAccess, + recenterAccessAddress: recenterAddress, + slot: slotHex, + checkedAtMs: now, + error: errorMessage, }; - if (parseFloat(balance) < 0.1) { - res.status(500).send(`Low Ethereum Balance: ${balance} ETH`); - } else { - res.status(200).json(status); - } - } catch (error) { - const err = error as { message?: string }; - res.status(500).send(`Error checking funds: ${err.message}`); + return lastRecenterAccessStatus; } -}); -app.post('/recenter', async (_req: Request, res: Response) => { - try { - const result = await attemptRecenter(); - if (!result.executed) { - return res.status(202).json(result); + async function evaluateRecenterOpportunity(): Promise { + const now = Date.now(); + const accessStatus = await getRecenterAccessStatus(true); + + if (accessStatus.error && accessStatus.hasAccess === null) { + lastRecenterEligibility = { + checkedAtMs: now, + canRecenter: false, + reason: 'Failed to determine recenter access.', + error: accessStatus.error, + }; + return lastRecenterEligibility; } - return res.status(200).json(result); - } catch (error) { - const err = error as { message?: string }; - logger.error('Manual recenter failed', { error }); - return res.status(500).json({ error: err.message || 'recenter failed' }); - } -}); -app.listen(PORT, () => { - logger.info(`HTTP server running on port ${PORT}`); -}); + if (accessStatus.hasAccess === false) { + lastRecenterEligibility = { + checkedAtMs: now, + canRecenter: false, + reason: 'txnBot is not the authorized recenter caller.', + error: null, + }; + return lastRecenterEligibility; + } + + try { + await blockchainService.estimateRecenterGas(); + lastRecenterEligibility = { + checkedAtMs: now, + canRecenter: true, + reason: null, + error: null, + }; + } catch (error) { + const err = error as { shortMessage?: string; message?: string }; + lastRecenterEligibility = { + checkedAtMs: now, + canRecenter: false, + reason: extractRevertReason(error) || 'recenter not currently executable.', + error: err?.shortMessage || err?.message || null, + }; + } + + return lastRecenterEligibility; + } + + async function attemptRecenter(): Promise { + const eligibility = await evaluateRecenterOpportunity(); + if (!eligibility.canRecenter) { + return { + executed: false, + message: eligibility.reason || 'Liquidity manager denied recenter.', + eligibility, + }; + } + + const tx = await blockchainService.recenter(); + lastRecenterTx = tx.hash; + await tx.wait(); + lastRecenterTime = new Date(); + return { + executed: true, + txHash: tx.hash, + message: 'recenter transaction submitted', + eligibility: lastRecenterEligibility, + }; + } + + async function checkPosition(position: Position): Promise { + const taxRate = Number(position.taxRate); + const lastTaxTime = Number(position.lastTaxTime); + if (isPositionDelinquent(lastTaxTime, taxRate)) { + const positionId = decodePositionId(position.id); + logger.info(`Calling payTax on ${positionId}`); + const tx = await blockchainService.payTax(positionId); + await tx.wait(); + lastLiquidationTime = new Date(); + return 1; + } + return 0; + } + + async function liquidityLoop(): Promise { + try { + const result = await attemptRecenter(); + if (result.executed) { + logger.info(`recenter called successfully. tx=${result.txHash}`); + } else { + const reason = result.message ? `Reason: ${result.message}` : 'Reason: unavailable'; + logger.info(`Recenter skipped. ${reason} - ${new Date().toISOString()}`); + } + } catch (error) { + logger.error('Error in liquidity loop', { error }); + } + } + + async function liquidationLoop(): Promise { + let counter = 0; + try { + const positions = await fetchActivePositions(); + for (const position of positions) { + counter = counter + (await checkPosition(position)); + } + if (counter === 0) { + logger.info(`No tax can be claimed at the moment. - ${new Date().toISOString()}`); + } + } catch (error) { + logger.error('Error in liquidation loop', { error }); + } + } + + const app = express(); + + app.use((req: Request, res: Response, next: NextFunction) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { + return res.sendStatus(204); + } + return next(); + }); + + app.get('/status', async (_req: Request, res: Response) => { + try { + const balance = await checkFunds(); + const uptime = formatDuration(new Date().getTime() - startTime.getTime()); + const recenterAccessStatus = await getRecenterAccessStatus(); + const recenterEligibility = lastRecenterEligibility; + const status = { + balance: `${balance} ETH`, + uptime, + lastRecenterTime: lastRecenterTime ? lastRecenterTime.toISOString() : 'Never', + lastLiquidationTime: lastLiquidationTime ? lastLiquidationTime.toISOString() : 'Never', + lastRecenterTx, + recenterAccess: { + hasAccess: recenterAccessStatus?.hasAccess ?? null, + grantedTo: recenterAccessStatus?.recenterAccessAddress ?? null, + slot: recenterAccessStatus?.slot ?? null, + checkedAt: recenterAccessStatus?.checkedAtMs ? new Date(recenterAccessStatus.checkedAtMs).toISOString() : null, + error: recenterAccessStatus?.error ?? null, + }, + recenterEligibility: { + canRecenter: recenterEligibility?.canRecenter ?? null, + reason: recenterEligibility?.reason ?? null, + checkedAt: recenterEligibility?.checkedAtMs ? new Date(recenterEligibility.checkedAtMs).toISOString() : null, + error: recenterEligibility?.error ?? null, + }, + }; + + if (parseFloat(balance) < 0.1) { + res.status(500).send(`Low Ethereum Balance: ${balance} ETH`); + } else { + res.status(200).json(status); + } + } catch (error) { + const err = error as { message?: string }; + res.status(500).send(`Error checking funds: ${err.message}`); + } + }); + + app.post('/recenter', async (_req: Request, res: Response) => { + try { + const result = await attemptRecenter(); + if (!result.executed) { + return res.status(202).json(result); + } + return res.status(200).json(result); + } catch (error) { + const err = error as { message?: string }; + logger.error('Manual recenter failed', { error }); + return res.status(500).json({ error: err.message || 'recenter failed' }); + } + }); + + async function start(): Promise { + logger.info(`txnBot service started for environment ${envConfig.ENVIRONMENT}`); + + await liquidityLoop(); + await liquidationLoop(); + setInterval(() => { + void liquidityLoop(); + }, 3 * 60000); + setInterval(() => { + void liquidationLoop(); + }, 20 * 60000); + + const port = resolvePort(configService.getPort()); + app.listen(port, () => { + logger.info(`HTTP server running on port ${configService.getPort()}`); + }); + } + + return { + app, + start, + getRecenterAccessStatus, + evaluateRecenterOpportunity, + attemptRecenter, + }; +} + +export async function bootstrap(): Promise { + const configService = new BotConfigService(); + const blockchainService = new BlockchainService({ + providerUrl: configService.getProviderUrl(), + privateKey: configService.getPrivateKey(), + liquidityManagerAddress: configService.getLiquidityManagerAddress(), + stakeContractAddress: configService.getStakeContractAddress(), + }); + const graphQLService = new GraphQLService(configService.getGraphQLEndpoint()); + + const bot = createTxnBot({ configService, blockchainService, graphQLService }); + await bot.start(); +} + +const isDirectRun = typeof process.argv[1] === 'string' && pathToFileURL(process.argv[1]).href === import.meta.url; + +if (isDirectRun) { + bootstrap().catch(error => { + logger.error('Fatal error', { error }); + }); +} diff --git a/services/txnBot/src/services/BlockchainService.ts b/services/txnBot/src/services/BlockchainService.ts new file mode 100644 index 0000000..ba13239 --- /dev/null +++ b/services/txnBot/src/services/BlockchainService.ts @@ -0,0 +1,69 @@ +import { Contract, JsonRpcProvider, TransactionResponse, Wallet, ethers } from 'ethers'; +import { RecenterAccessReader } from '../recenterAccess.js'; + +export interface BlockchainConfig { + providerUrl: string; + privateKey: string; + liquidityManagerAddress: string; + stakeContractAddress: string; +} + +const LM_ABI = [ + { type: 'function', name: 'recenter', inputs: [], outputs: [], stateMutability: 'nonpayable' }, + { type: 'function', name: 'feeDestination', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' }, + { type: 'function', name: 'recenterAccess', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' }, +]; + +const STAKE_ABI = [ + { + inputs: [{ internalType: 'uint256', name: 'positionId', type: 'uint256' }], + name: 'payTax', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +]; + +export class BlockchainService { + private readonly provider: JsonRpcProvider; + private readonly wallet: Wallet; + private readonly liquidityManager: Contract; + private readonly stakeContract: Contract; + + constructor(config: BlockchainConfig) { + this.provider = new ethers.JsonRpcProvider(config.providerUrl); + this.wallet = new ethers.Wallet(config.privateKey, this.provider); + this.liquidityManager = new ethers.Contract(config.liquidityManagerAddress, LM_ABI, this.wallet); + this.stakeContract = new ethers.Contract(config.stakeContractAddress, STAKE_ABI, this.wallet); + } + + getWalletAddress(): string { + return ethers.getAddress(this.wallet.address); + } + + getRecenterAccessReader(): RecenterAccessReader { + return { + recenterAccess: async (): Promise => { + const method = this.liquidityManager.getFunction('recenterAccess'); + return (await method()) as string; + }, + }; + } + + async checkFunds(): Promise { + const balance = await this.provider.getBalance(this.wallet.address); + return ethers.formatEther(balance); + } + + async estimateRecenterGas(): Promise { + await this.liquidityManager.recenter.estimateGas(); + } + + async recenter(): Promise { + return (await this.liquidityManager.recenter()) as TransactionResponse; + } + + async payTax(positionId: bigint): Promise { + return (await this.stakeContract.payTax(positionId)) as TransactionResponse; + } +} diff --git a/services/txnBot/src/services/BotConfigService.ts b/services/txnBot/src/services/BotConfigService.ts new file mode 100644 index 0000000..06d4be6 --- /dev/null +++ b/services/txnBot/src/services/BotConfigService.ts @@ -0,0 +1,83 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; +import type { ProcessEnv } from 'node:process'; +import { EnvConfig } from '../types.js'; + +export class BotConfigService { + private readonly config: EnvConfig; + + constructor(private readonly env: ProcessEnv = process.env) { + this.loadEnvFile(); + this.config = this.resolveConfig(); + } + + getConfig(): EnvConfig { + return { ...this.config }; + } + + getPort(): string { + return this.config.PORT; + } + + getEnvironment(): string { + return this.config.ENVIRONMENT; + } + + getGraphQLEndpoint(): string { + return this.config.GRAPHQL_ENDPOINT; + } + + getProviderUrl(): string { + return this.config.PROVIDER_URL; + } + + getPrivateKey(): string { + return this.config.PRIVATE_KEY; + } + + getLiquidityManagerAddress(): string { + return this.config.LM_CONTRACT_ADDRESS; + } + + getStakeContractAddress(): string { + return this.config.STAKE_CONTRACT_ADDRESS; + } + + private loadEnvFile(): void { + const envFile = this.env.TXN_BOT_ENV_FILE; + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const defaultEnvPath = path.resolve(__dirname, '../..', '.env'); + const dotenvPath = envFile ? path.resolve(envFile) : defaultEnvPath; + + dotenv.config({ path: dotenvPath }); + } + + private resolveConfig(): EnvConfig { + const requiredKeys: Array = [ + 'PROVIDER_URL', + 'PRIVATE_KEY', + 'LM_CONTRACT_ADDRESS', + 'STAKE_CONTRACT_ADDRESS', + 'GRAPHQL_ENDPOINT', + ]; + + for (const key of requiredKeys) { + const value = this.env[key]; + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + } + + return { + PROVIDER_URL: this.env.PROVIDER_URL as string, + PRIVATE_KEY: this.env.PRIVATE_KEY as string, + LM_CONTRACT_ADDRESS: this.env.LM_CONTRACT_ADDRESS as string, + STAKE_CONTRACT_ADDRESS: this.env.STAKE_CONTRACT_ADDRESS as string, + GRAPHQL_ENDPOINT: this.env.GRAPHQL_ENDPOINT as string, + ENVIRONMENT: this.env.ENVIRONMENT ?? 'UNSPECIFIED', + PORT: this.env.PORT ?? '3000', + }; + } +} diff --git a/services/txnBot/src/services/GraphQLService.ts b/services/txnBot/src/services/GraphQLService.ts new file mode 100644 index 0000000..1f0175c --- /dev/null +++ b/services/txnBot/src/services/GraphQLService.ts @@ -0,0 +1,38 @@ +import { GraphQLResponse, Position } from '../types.js'; + +type FetchFn = typeof fetch; + +export class GraphQLService { + constructor( + private readonly endpoint: string, + private readonly fetchFn: FetchFn = fetch + ) { + if (!endpoint) { + throw new Error('GraphQL endpoint is required'); + } + } + + getEndpoint(): string { + return this.endpoint; + } + + async fetchActivePositions(query: string): Promise { + const response = await this.fetchFn(this.endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + }); + + if (!response.ok) { + throw new Error(`GraphQL request failed with status ${response.status}`); + } + + const payload = (await response.json()) as GraphQLResponse; + if (payload.errors && payload.errors.length > 0) { + const messages = payload.errors.map(error => error.message).join('; '); + throw new Error(`GraphQL responded with errors: ${messages}`); + } + + return payload.data?.positionss?.items ?? []; + } +}