- Remove permanently unreachable guard branches from evaluateRecenterOpportunity - Remove orphaned getWalletAddress() from BlockchainService - Simplify RecenterAccessStatus type: drop always-null recenterAccessAddress and slot fields, narrow hasAccess to boolean - Update /status endpoint to match simplified type - Remove test script (no test files remain) - Revert unrelated kraiken-lib/package-lock.json churn Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
import express, { NextFunction, Request, Response } from 'express';
|
|
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 { 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
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
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 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;
|
|
}
|
|
|
|
lastRecenterAccessStatus = {
|
|
hasAccess: true,
|
|
checkedAtMs: now,
|
|
error: null,
|
|
};
|
|
|
|
return lastRecenterAccessStatus;
|
|
}
|
|
|
|
async function evaluateRecenterOpportunity(): Promise<RecenterEligibility> {
|
|
const now = Date.now();
|
|
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 ?? true,
|
|
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 });
|
|
});
|
|
}
|