import path from 'path'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; import { ethers } from 'ethers'; import express from 'express'; import { decodePositionId } from 'kraiken-lib/ids'; import { isPositionDelinquent } from 'kraiken-lib/staking'; 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 } } } `; // Load environment variables 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 = { 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"} ]; const STAKE_ABI = [ {"inputs":[{"internalType":"uint256","name":"positionId","type":"uint256"}],"name":"payTax","outputs":[],"stateMutability":"nonpayable","type":"function"} ]; // Initialize the provider const provider = new ethers.JsonRpcProvider(PROVIDER_URL); const wallet = new ethers.Wallet(PRIVATE_KEY, provider); const liquidityManager = new ethers.Contract(LM_CONTRACT_ADDRESS, LM_ABI, wallet); const stakeContract = new ethers.Contract(STAKE_CONTRACT_ADDRESS, STAKE_ABI, wallet); const walletAddress = ethers.getAddress(wallet.address); const ZERO_ADDRESS = ethers.ZeroAddress; const DEFAULT_RECENTER_ACCESS_SLOT = 6n; const STORAGE_SLOT_SEARCH_LIMIT = 64n; let recenterAccessSlot = null; let slotDetectionAttempted = false; let startTime = new Date(); let lastRecenterTime = null; let lastLiquidationTime = null; let lastRecenterTx = null; let lastRecenterAccessStatus = null; let lastRecenterEligibility = { checkedAtMs: null, canRecenter: null, reason: null, error: null, }; async function fetchActivePositions() { const response = await fetch(GRAPHQL_ENDPOINT, { 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(); 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() { const balance = await provider.getBalance(wallet.address); return ethers.formatEther(balance); } function extractRevertReason(error) { const candidates = [ error?.shortMessage, error?.reason, error?.info?.error?.message, error?.error?.message, error?.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 parseStorageAddress(value) { if (typeof value !== 'string' || value.length <= 2) { return ZERO_ADDRESS; } try { return ethers.getAddress(ethers.dataSlice(value, 12, 32)); } catch (error) { return ZERO_ADDRESS; } } async function detectRecenterAccessSlot() { if (recenterAccessSlot !== null) { return recenterAccessSlot; } if (slotDetectionAttempted) { return DEFAULT_RECENTER_ACCESS_SLOT; } slotDetectionAttempted = true; try { const feeDestinationAddress = await liquidityManager.feeDestination(); if (feeDestinationAddress !== ZERO_ADDRESS) { for (let slot = 0n; slot < STORAGE_SLOT_SEARCH_LIMIT; slot++) { const raw = await provider.getStorage(LM_CONTRACT_ADDRESS, slot); if (parseStorageAddress(raw) === feeDestinationAddress) { recenterAccessSlot = slot > 0n ? slot - 1n : DEFAULT_RECENTER_ACCESS_SLOT; break; } } } } catch (error) { console.error('Failed to detect recenter access slot:', error); } if (recenterAccessSlot === null) { recenterAccessSlot = DEFAULT_RECENTER_ACCESS_SLOT; } return recenterAccessSlot; } async function getRecenterAccessStatus(forceRefresh = false) { const now = Date.now(); if (!forceRefresh && lastRecenterAccessStatus && lastRecenterAccessStatus.checkedAtMs && (now - lastRecenterAccessStatus.checkedAtMs) < 30000) { return lastRecenterAccessStatus; } let recenterAddress = null; let hasAccess = null; let slotHex = null; let errorMessage = null; try { const slot = await detectRecenterAccessSlot(); slotHex = ethers.toBeHex(slot); const raw = await provider.getStorage(LM_CONTRACT_ADDRESS, slot); recenterAddress = parseStorageAddress(raw); hasAccess = recenterAddress === ZERO_ADDRESS || recenterAddress === walletAddress; } catch (error) { errorMessage = error?.shortMessage || error?.message || 'unknown error'; recenterAddress = null; } lastRecenterAccessStatus = { hasAccess, recenterAccessAddress: recenterAddress, slot: slotHex, checkedAtMs: now, error: errorMessage, }; return lastRecenterAccessStatus; } async function evaluateRecenterOpportunity() { 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) { lastRecenterEligibility = { checkedAtMs: now, canRecenter: false, reason: extractRevertReason(error) || 'recenter not currently executable.', error: error?.shortMessage || error?.message || null, }; } return lastRecenterEligibility; } async function attemptRecenter() { const eligibility = await evaluateRecenterOpportunity(); if (!eligibility.canRecenter) { return { executed: false, message: eligibility.reason || 'Liquidity manager denied recenter.', eligibility, }; } const tx = await liquidityManager.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) { const taxRate = Number(position.taxRate); const lastTaxTime = Number(position.lastTaxTime); if (isPositionDelinquent(lastTaxTime, taxRate)) { const positionId = decodePositionId(position.id); console.log(`Calling payTax on ${positionId}`); const tx = await stakeContract.payTax(positionId); await tx.wait(); lastLiquidationTime = new Date(); return 1; } else { return 0; } } function formatDuration(ms) { let seconds = Math.floor(ms / 1000); let minutes = Math.floor(seconds / 60); let hours = Math.floor(minutes / 60); let days = Math.floor(hours / 24); seconds = seconds % 60; minutes = minutes % 60; hours = hours % 24; return `${days} days, ${hours} hours, ${minutes} minutes`; } async function liquidityLoop() { try { const result = await attemptRecenter(); if (result.executed) { console.log(`recenter called successfully. tx=${result.txHash}`); } else { const reason = result.message ? `Reason: ${result.message}` : 'Reason: unavailable'; console.log(`Recenter skipped. ${reason} - ${(new Date()).toISOString()}`); } } catch (error) { console.error('Error in liquidity loop:', error); } } async function liquidationLoop() { let counter = 0; try { const positions = await fetchActivePositions(); for (const position of positions) { counter = counter + await checkPosition(position); } if (counter == 0) { console.log(`No tax can be claimed at the moment. - ${(new Date()).toISOString()}`); } } catch (error) { console.error('Error in liquidation loop:', error); } } async function main() { console.log(`txnBot service started for environment ${ENVIRONMENT}`); await liquidityLoop(); await liquidationLoop(); setInterval(liquidityLoop, 3 * 60000); // 3 minute setInterval(liquidationLoop, 20 * 60000); // 20 minutes } // Start the main loop main().catch(async (error) => { console.error('Fatal error:', error); }); // Set up the Express server const app = express(); const PORT = process.env.PORT || 3000; app.use((req, res, next) => { 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, res) => { try { const balance = await checkFunds(); const uptime = formatDuration(new Date() - startTime); 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) { res.status(500).send(`Error checking funds: ${error.message}`); } }); app.post('/recenter', async (req, res) => { try { const result = await attemptRecenter(); if (!result.executed) { return res.status(202).json(result); } return res.status(200).json(result); } catch (error) { console.error('Manual recenter failed:', error); return res.status(500).json({ error: error.message || 'recenter failed' }); } }); app.listen(PORT, () => { console.log(`HTTP server running on port ${PORT}`); });