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 } from './recenterAccess.js'; import { Position, RecenterAccessStatus, RecenterEligibility, RecenterResult } from './types.js'; const ACTIVE_POSITIONS_QUERY = ` query ActivePositions { positionss(where: { status: "Active" }, limit: 1000) { items { id share lastTaxTime taxRate status } } } `; const ZERO_ADDRESS = ethers.ZeroAddress; export interface TxnBotDependencies { configService: BotConfigService; blockchainService: BlockchainService; graphQLService: GraphQLService; } export interface TxnBotInstance { app: express.Application; start: () => Promise; getRecenterAccessStatus: (forceRefresh?: boolean) => Promise; evaluateRecenterOpportunity: () => Promise; attemptRecenter: () => Promise; } 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 { const err = error as { shortMessage?: string; reason?: string; info?: { error?: { message?: string } }; error?: { message?: string }; message?: string; }; const candidates = [err?.shortMessage, err?.reason, err?.info?.error?.message, err?.error?.message, err?.message]; for (const candidate of candidates) { if (typeof candidate !== 'string' || candidate.length === 0) { continue; } const match = candidate.match(/execution reverted(?: due to)?(?::|: )?(.*)/i); if (match && match[1]) { return match[1].trim(); } if (!candidate.toLowerCase().startsWith('execution reverted')) { return candidate.trim(); } } return null; } function formatDuration(ms: number): string { let seconds = Math.floor(ms / 1000); let minutes = Math.floor(seconds / 60); let hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); seconds = seconds % 60; minutes = minutes % 60; hours = hours % 24; return `${days} days, ${hours} hours, ${minutes} minutes`; } 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 checkFunds(): Promise { return blockchainService.checkFunds(); } 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 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 }); }); }