txnbot refactor

This commit is contained in:
johba 2025-10-11 15:37:56 +00:00
parent bd475c2271
commit f039221d4a
4 changed files with 489 additions and 311 deletions

View file

@ -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<EnvConfig> = {
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<string> => {
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<Position[]> {
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<void>;
getRecenterAccessStatus: (forceRefresh?: boolean) => Promise<RecenterAccessStatus>;
evaluateRecenterOpportunity: () => Promise<RecenterEligibility>;
attemptRecenter: () => Promise<RecenterResult>;
}
async function checkFunds(): Promise<string> {
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,7 +75,46 @@ function extractRevertReason(error: unknown): string | null {
return null;
}
async function getRecenterAccessStatus(forceRefresh = false): Promise<RecenterAccessStatus> {
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 &&
@ -179,9 +150,9 @@ async function getRecenterAccessStatus(forceRefresh = false): Promise<RecenterAc
};
return lastRecenterAccessStatus;
}
}
async function evaluateRecenterOpportunity(): Promise<RecenterEligibility> {
async function evaluateRecenterOpportunity(): Promise<RecenterEligibility> {
const now = Date.now();
const accessStatus = await getRecenterAccessStatus(true);
@ -206,7 +177,7 @@ async function evaluateRecenterOpportunity(): Promise<RecenterEligibility> {
}
try {
await liquidityManager.recenter.estimateGas();
await blockchainService.estimateRecenterGas();
lastRecenterEligibility = {
checkedAtMs: now,
canRecenter: true,
@ -224,9 +195,9 @@ async function evaluateRecenterOpportunity(): Promise<RecenterEligibility> {
}
return lastRecenterEligibility;
}
}
async function attemptRecenter(): Promise<RecenterResult> {
async function attemptRecenter(): Promise<RecenterResult> {
const eligibility = await evaluateRecenterOpportunity();
if (!eligibility.canRecenter) {
return {
@ -236,7 +207,7 @@ async function attemptRecenter(): Promise<RecenterResult> {
};
}
const tx: TransactionResponse = (await liquidityManager.recenter()) as TransactionResponse;
const tx = await blockchainService.recenter();
lastRecenterTx = tx.hash;
await tx.wait();
lastRecenterTime = new Date();
@ -246,37 +217,23 @@ async function attemptRecenter(): Promise<RecenterResult> {
message: 'recenter transaction submitted',
eligibility: lastRecenterEligibility,
};
}
}
async function checkPosition(position: Position): Promise<number> {
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: TransactionResponse = (await stakeContract.payTax(positionId)) as TransactionResponse;
const tx = await blockchainService.payTax(positionId);
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);
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`;
}
async function liquidityLoop(): Promise<void> {
async function liquidityLoop(): Promise<void> {
try {
const result = await attemptRecenter();
if (result.executed) {
@ -288,9 +245,9 @@ async function liquidityLoop(): Promise<void> {
} catch (error) {
logger.error('Error in liquidity loop', { error });
}
}
}
async function liquidationLoop(): Promise<void> {
async function liquidationLoop(): Promise<void> {
let counter = 0;
try {
const positions = await fetchActivePositions();
@ -303,25 +260,11 @@ async function liquidationLoop(): Promise<void> {
} catch (error) {
logger.error('Error in liquidation loop', { error });
}
}
}
async function main(): Promise<void> {
logger.info(`txnBot service started for environment ${ENVIRONMENT}`);
const app = express();
await liquidityLoop();
await liquidationLoop();
setInterval(liquidityLoop, 3 * 60000); // 3 minute
setInterval(liquidationLoop, 20 * 60000); // 20 minutes
}
main().catch(async error => {
logger.error('Fatal error', { error });
});
const app = express();
const PORT = process.env.PORT || 3000;
app.use((req: Request, res: Response, next: NextFunction) => {
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');
@ -329,9 +272,9 @@ app.use((req: Request, res: Response, next: NextFunction) => {
return res.sendStatus(204);
}
return next();
});
});
app.get('/status', async (_req: Request, res: Response) => {
app.get('/status', async (_req: Request, res: Response) => {
try {
const balance = await checkFunds();
const uptime = formatDuration(new Date().getTime() - startTime.getTime());
@ -339,7 +282,7 @@ app.get('/status', async (_req: Request, res: Response) => {
const recenterEligibility = lastRecenterEligibility;
const status = {
balance: `${balance} ETH`,
uptime: uptime,
uptime,
lastRecenterTime: lastRecenterTime ? lastRecenterTime.toISOString() : 'Never',
lastLiquidationTime: lastLiquidationTime ? lastLiquidationTime.toISOString() : 'Never',
lastRecenterTx,
@ -367,9 +310,9 @@ app.get('/status', async (_req: Request, res: Response) => {
const err = error as { message?: string };
res.status(500).send(`Error checking funds: ${err.message}`);
}
});
});
app.post('/recenter', async (_req: Request, res: Response) => {
app.post('/recenter', async (_req: Request, res: Response) => {
try {
const result = await attemptRecenter();
if (!result.executed) {
@ -381,8 +324,53 @@ app.post('/recenter', async (_req: Request, res: Response) => {
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}`);
});
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 });
});
}

View file

@ -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<string> => {
const method = this.liquidityManager.getFunction('recenterAccess');
return (await method()) as string;
},
};
}
async checkFunds(): Promise<string> {
const balance = await this.provider.getBalance(this.wallet.address);
return ethers.formatEther(balance);
}
async estimateRecenterGas(): Promise<void> {
await this.liquidityManager.recenter.estimateGas();
}
async recenter(): Promise<TransactionResponse> {
return (await this.liquidityManager.recenter()) as TransactionResponse;
}
async payTax(positionId: bigint): Promise<TransactionResponse> {
return (await this.stakeContract.payTax(positionId)) as TransactionResponse;
}
}

View file

@ -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<keyof EnvConfig> = [
'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',
};
}
}

View file

@ -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<Position[]> {
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 ?? [];
}
}