harb/services/txnBot/src/service.ts
2025-10-11 15:47:16 +00:00

376 lines
12 KiB
TypeScript

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<void>;
getRecenterAccessStatus: (forceRefresh?: boolean) => Promise<RecenterAccessStatus>;
evaluateRecenterOpportunity: () => Promise<RecenterEligibility>;
attemptRecenter: () => Promise<RecenterResult>;
}
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<Position[]> {
return graphQLService.fetchActivePositions(ACTIVE_POSITIONS_QUERY);
}
async function checkFunds(): Promise<string> {
return blockchainService.checkFunds();
}
async function getRecenterAccessStatus(forceRefresh = false): Promise<RecenterAccessStatus> {
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<RecenterEligibility> {
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<RecenterResult> {
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<number> {
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<void> {
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<void> {
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<void> {
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<void> {
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 });
});
}