txnbot - rewrite and lint (#53)

resolves #46

Co-authored-by: johba <johba@harb.eth>
Reviewed-on: https://codeberg.org/johba/harb/pulls/53
This commit is contained in:
johba 2025-10-04 15:40:30 +02:00
parent dc61771dfc
commit 514be62cbb
19 changed files with 736 additions and 429 deletions

View file

@ -0,0 +1,13 @@
import { Wallet } from 'ethers';
import { logger } from './logger.js';
function generateKey(): void {
const wallet = Wallet.createRandom();
const privateKey = wallet.privateKey;
const address = wallet.address;
logger.info('Generated txnBot wallet', { privateKey, address });
}
generateKey();

View file

@ -0,0 +1,56 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
type LogLevel = 'info' | 'warn' | 'error';
interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
data?: unknown;
}
export class Logger {
private logFilePath: string;
constructor(logFileName = 'txnbot.log') {
this.logFilePath = path.resolve(__dirname, '..', logFileName);
}
private writeLog(level: LogLevel, message: string, data?: unknown): void {
const logEntry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
...(data !== undefined && { data }),
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this.logFilePath, logLine, 'utf8');
} catch (error) {
// Fallback to stderr if file write fails
process.stderr.write(`Failed to write log: ${error}\n`);
process.stderr.write(logLine);
}
}
info(message: string, data?: unknown): void {
this.writeLog('info', message, data);
}
warn(message: string, data?: unknown): void {
this.writeLog('warn', message, data);
}
error(message: string, data?: unknown): void {
this.writeLog('error', message, data);
}
}
export const logger = new Logger();

View file

@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ethers } from 'ethers';
import { hasRecenterAccess, readRecenterAccess, type RecenterAccessReader } from './recenterAccess.js';
const ZERO_ADDRESS = ethers.ZeroAddress;
class MockRecenterAccessReader implements RecenterAccessReader {
constructor(
private readonly value: string,
private readonly shouldThrow = false
) {}
async recenterAccess(): Promise<string> {
if (this.shouldThrow) {
throw new Error('read failed');
}
return this.value;
}
}
test('readRecenterAccess returns zero address for empty or zero values', async () => {
const reader = new MockRecenterAccessReader('0x0000000000000000000000000000000000000000');
assert.equal(await readRecenterAccess(reader, ZERO_ADDRESS), ZERO_ADDRESS);
const emptyReader = new MockRecenterAccessReader('');
assert.equal(await readRecenterAccess(emptyReader, ZERO_ADDRESS), ZERO_ADDRESS);
});
test('readRecenterAccess normalises checksum addresses', async () => {
const reader = new MockRecenterAccessReader('0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc');
assert.equal(await readRecenterAccess(reader, ZERO_ADDRESS), '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC');
});
test('readRecenterAccess throws when reader fails', async () => {
const reader = new MockRecenterAccessReader('0x0', true);
await assert.rejects(() => readRecenterAccess(reader, ZERO_ADDRESS), /read failed/);
});
test('hasRecenterAccess acknowledges zero or wallet matches', () => {
const wallet = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC';
assert.equal(hasRecenterAccess(ZERO_ADDRESS, wallet, ZERO_ADDRESS), true);
assert.equal(hasRecenterAccess(wallet, wallet, ZERO_ADDRESS), true);
assert.equal(hasRecenterAccess('0x5cFB5CDd3E8723ba98312c90a43a4d6Ac6121240', wallet, ZERO_ADDRESS), false);
});

View file

@ -0,0 +1,40 @@
import { ethers } from 'ethers';
export interface RecenterAccessReader {
recenterAccess(): Promise<string>;
}
export async function readRecenterAccess(reader: RecenterAccessReader, zeroAddress: string): Promise<string> {
let raw: string;
try {
raw = await reader.recenterAccess();
} catch (error) {
throw new Error(`Failed to read recenterAccess: ${(error as Error).message}`);
}
if (typeof raw !== 'string' || raw.length === 0) {
return zeroAddress;
}
if (raw === zeroAddress) {
return zeroAddress;
}
try {
return ethers.getAddress(raw);
} catch (error) {
throw new Error(`Invalid recenterAccess address: ${(error as Error).message}`);
}
}
export function hasRecenterAccess(recenterAddress: string, walletAddress: string, zeroAddress: string): boolean {
if (recenterAddress === zeroAddress) {
return true;
}
try {
return ethers.getAddress(recenterAddress) === ethers.getAddress(walletAddress);
} catch {
return false;
}
}

View file

@ -0,0 +1,388 @@
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 { decodePositionId } from 'kraiken-lib/ids';
import { isPositionDelinquent } from 'kraiken-lib/staking';
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 });
const ACTIVE_POSITIONS_QUERY = `
query ActivePositions {
positionss(where: { status: "Active" }, limit: 1000) {
items {
id
share
lastTaxTime
taxRate
status
}
}
}
`;
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 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}`);
}
}
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 ?? [];
}
async function checkFunds(): Promise<string> {
const balance = await provider.getBalance(wallet.address);
return ethers.formatEther(balance);
}
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;
}
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 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<RecenterResult> {
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<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;
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> {
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 });
}
}
async function main(): Promise<void> {
logger.info(`txnBot service started for environment ${ENVIRONMENT}`);
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) => {
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,
},
};
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' });
}
});
app.listen(PORT, () => {
logger.info(`HTTP server running on port ${PORT}`);
});

View file

@ -0,0 +1,48 @@
export interface Position {
id: string;
share: string;
lastTaxTime: string;
taxRate: string;
status: string;
}
export interface EnvConfig {
PROVIDER_URL: string;
PRIVATE_KEY: string;
LM_CONTRACT_ADDRESS: string;
STAKE_CONTRACT_ADDRESS: string;
GRAPHQL_ENDPOINT: string;
ENVIRONMENT: string;
PORT: string;
}
export interface RecenterAccessStatus {
hasAccess: boolean | null;
recenterAccessAddress: string | null;
slot: string | null;
checkedAtMs: number;
error: string | null;
}
export interface RecenterEligibility {
checkedAtMs: number;
canRecenter: boolean;
reason: string | null;
error: string | null;
}
export interface RecenterResult {
executed: boolean;
message?: string;
txHash?: string;
eligibility: RecenterEligibility;
}
export interface GraphQLResponse {
data?: {
positionss?: {
items?: Position[];
};
};
errors?: Array<{ message: string }>;
}