408 lines
13 KiB
JavaScript
408 lines
13 KiB
JavaScript
const path = require('path');
|
|
const dotenvPath = process.env.TXN_BOT_ENV_FILE
|
|
? path.resolve(process.env.TXN_BOT_ENV_FILE)
|
|
: path.resolve(__dirname, '.env');
|
|
|
|
require('dotenv').config({ path: dotenvPath });
|
|
const { ethers } = require('ethers');
|
|
const express = require('express');
|
|
const { decodePositionId } = require('kraiken-lib/ids');
|
|
const { isPositionDelinquent } = require('kraiken-lib/staking');
|
|
|
|
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}`);
|
|
});
|