Merge pull request 'feature/txn-bot-service-refactor' (#80) from feature/txn-bot-service-refactor into master
Reviewed-on: https://codeberg.org/johba/harb/pulls/80
This commit is contained in:
commit
beea5f67f9
4 changed files with 489 additions and 311 deletions
|
|
@ -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,126 +75,6 @@ function extractRevertReason(error: unknown): string | null {
|
|||
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);
|
||||
|
|
@ -276,113 +88,289 @@ function formatDuration(ms: number): string {
|
|||
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 });
|
||||
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 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 checkFunds(): Promise<string> {
|
||||
return blockchainService.checkFunds();
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
logger.info(`txnBot service started for environment ${ENVIRONMENT}`);
|
||||
async function getRecenterAccessStatus(forceRefresh = false): Promise<RecenterAccessStatus> {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!forceRefresh &&
|
||||
lastRecenterAccessStatus &&
|
||||
lastRecenterAccessStatus.checkedAtMs &&
|
||||
now - lastRecenterAccessStatus.checkedAtMs < 30000
|
||||
) {
|
||||
return lastRecenterAccessStatus;
|
||||
}
|
||||
|
||||
await liquidityLoop();
|
||||
await liquidationLoop();
|
||||
setInterval(liquidityLoop, 3 * 60000); // 3 minute
|
||||
setInterval(liquidationLoop, 20 * 60000); // 20 minutes
|
||||
}
|
||||
let recenterAddress: string | null = null;
|
||||
let hasAccess: boolean | null = null;
|
||||
let slotHex: string | null = null;
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
main().catch(async error => {
|
||||
logger.error('Fatal error', { error });
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
lastRecenterAccessStatus = {
|
||||
hasAccess,
|
||||
recenterAccessAddress: recenterAddress,
|
||||
slot: slotHex,
|
||||
checkedAtMs: now,
|
||||
error: errorMessage,
|
||||
};
|
||||
|
||||
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}`);
|
||||
return lastRecenterAccessStatus;
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/recenter', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await attemptRecenter();
|
||||
if (!result.executed) {
|
||||
return res.status(202).json(result);
|
||||
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;
|
||||
}
|
||||
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}`);
|
||||
});
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
69
services/txnBot/src/services/BlockchainService.ts
Normal file
69
services/txnBot/src/services/BlockchainService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
83
services/txnBot/src/services/BotConfigService.ts
Normal file
83
services/txnBot/src/services/BotConfigService.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
38
services/txnBot/src/services/GraphQLService.ts
Normal file
38
services/txnBot/src/services/GraphQLService.ts
Normal 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 ?? [];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue