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:
parent
dc61771dfc
commit
514be62cbb
19 changed files with 736 additions and 429 deletions
|
|
@ -10,6 +10,10 @@ if [ -d "kraiken-lib" ]; then
|
||||||
(cd kraiken-lib && npx lint-staged)
|
(cd kraiken-lib && npx lint-staged)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "services/txnBot" ]; then
|
||||||
|
(cd services/txnBot && npx lint-staged)
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -d "web-app" ]; then
|
if [ -d "web-app" ]; then
|
||||||
(cd web-app && npx lint-staged)
|
(cd web-app && npx lint-staged)
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -43,5 +43,8 @@ npm install --no-save --loglevel error 2>&1 || {
|
||||||
npm install --force --no-save --loglevel error
|
npm install --force --no-save --loglevel error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
echo "[txn-bot-entrypoint] Building TypeScript..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
export TXN_BOT_ENV_FILE="$TXNBOT_ENV_FILE"
|
export TXN_BOT_ENV_FILE="$TXNBOT_ENV_FILE"
|
||||||
exec npm run start
|
exec npm run start
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@
|
||||||
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||||
"format": "prettier --write 'src/**/*.ts'",
|
"format": "prettier --write 'src/**/*.ts'",
|
||||||
"format:check": "prettier --check 'src/**/*.ts'",
|
"format:check": "prettier --check 'src/**/*.ts'",
|
||||||
"prepare": "husky install"
|
"prepare": "husky install || true"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"src/**/*.ts": [
|
"src/**/*.ts": [
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
|
||||||
PoolKey private poolKey;
|
PoolKey private poolKey;
|
||||||
|
|
||||||
/// @notice Access control and fee management
|
/// @notice Access control and fee management
|
||||||
address private recenterAccess;
|
address public recenterAccess;
|
||||||
address public feeDestination;
|
address public feeDestination;
|
||||||
|
|
||||||
/// @notice Custom errors
|
/// @notice Custom errors
|
||||||
|
|
|
||||||
6
services/txnBot/.lintstagedrc.json
Normal file
6
services/txnBot/.lintstagedrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"src/**/*.ts": [
|
||||||
|
"eslint --fix",
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
|
}
|
||||||
8
services/txnBot/.prettierrc
Normal file
8
services/txnBot/.prettierrc
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 140,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
}
|
||||||
|
|
@ -36,3 +36,7 @@ npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
The service exposes a lightweight status endpoint at `GET /status` reporting uptime, balances, and the most recent automation activity.
|
The service exposes a lightweight status endpoint at `GET /status` reporting uptime, balances, and the most recent automation activity.
|
||||||
|
|
||||||
|
## Generate a New Operator Key
|
||||||
|
|
||||||
|
Run `npm run generate-key` to produce a fresh EOA. The command writes the private key and address as JSON log entries to `txnbot.log`. Be sure to rotate the log or move the credentials to a secure store immediately after use.
|
||||||
|
|
|
||||||
65
services/txnBot/eslint.config.js
Normal file
65
services/txnBot/eslint.config.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||||
|
import tsparser from '@typescript-eslint/parser';
|
||||||
|
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
eslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsparser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'module',
|
||||||
|
project: './tsconfig.json',
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
process: 'readonly',
|
||||||
|
fetch: 'readonly',
|
||||||
|
setInterval: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': tseslint,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
indent: ['error', 2, { SwitchCase: 1 }],
|
||||||
|
'max-len': ['error', { code: 140, ignoreUrls: true, ignoreStrings: true }],
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'@typescript-eslint/explicit-function-return-type': ['error', { allowExpressions: true }],
|
||||||
|
'@typescript-eslint/naming-convention': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector: 'variable',
|
||||||
|
format: ['camelCase', 'UPPER_CASE'],
|
||||||
|
filter: {
|
||||||
|
regex: '^__',
|
||||||
|
match: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'function',
|
||||||
|
format: ['camelCase'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'typeLike',
|
||||||
|
format: ['PascalCase'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'no-console': 'error',
|
||||||
|
complexity: 'off',
|
||||||
|
'max-lines': 'off',
|
||||||
|
'max-statements': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eslintConfigPrettier,
|
||||||
|
];
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
const { Wallet } = require('ethers');
|
|
||||||
|
|
||||||
// Generate a random wallet
|
|
||||||
const wallet = Wallet.createRandom();
|
|
||||||
|
|
||||||
// Extract the private key and address
|
|
||||||
const privateKey = wallet.privateKey;
|
|
||||||
const address = wallet.address;
|
|
||||||
|
|
||||||
console.log('Private Key:', privateKey);
|
|
||||||
console.log('Address:', address);
|
|
||||||
|
|
@ -2,15 +2,38 @@
|
||||||
"name": "marketMaker",
|
"name": "marketMaker",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "dist/service.js",
|
||||||
"license": "GPL3",
|
"license": "GPL3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node service.js"
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/service.js",
|
||||||
|
"dev": "tsx watch src/service.ts",
|
||||||
|
"test": "node --test --import tsx src/recenterAccess.test.ts",
|
||||||
|
"lint": "eslint src/**/*.ts",
|
||||||
|
"lint:fix": "eslint --fix src/**/*.ts",
|
||||||
|
"format": "prettier --write src/**/*.ts",
|
||||||
|
"format:check": "prettier --check src/**/*.ts",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"prepare": "husky install || true",
|
||||||
|
"generate-key": "tsx src/generateKey.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"ethers": "^6.13.2",
|
"ethers": "^6.13.2",
|
||||||
"express": "^5.0.0",
|
"express": "^5.0.0",
|
||||||
"kraiken-lib": "file:../../kraiken-lib"
|
"kraiken-lib": "file:../../kraiken-lib"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/node": "^24.6.2",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.45.0",
|
||||||
|
"@typescript-eslint/parser": "^8.45.0",
|
||||||
|
"eslint": "^9.36.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^16.2.3",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,414 +0,0 @@
|
||||||
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}`);
|
|
||||||
});
|
|
||||||
13
services/txnBot/src/generateKey.ts
Normal file
13
services/txnBot/src/generateKey.ts
Normal 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();
|
||||||
56
services/txnBot/src/logger.ts
Normal file
56
services/txnBot/src/logger.ts
Normal 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();
|
||||||
45
services/txnBot/src/recenterAccess.test.ts
Normal file
45
services/txnBot/src/recenterAccess.test.ts
Normal 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);
|
||||||
|
});
|
||||||
40
services/txnBot/src/recenterAccess.ts
Normal file
40
services/txnBot/src/recenterAccess.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
388
services/txnBot/src/service.ts
Normal file
388
services/txnBot/src/service.ts
Normal 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}`);
|
||||||
|
});
|
||||||
48
services/txnBot/src/types.ts
Normal file
48
services/txnBot/src/types.ts
Normal 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 }>;
|
||||||
|
}
|
||||||
4
services/txnBot/tsconfig.build.json
Normal file
4
services/txnBot/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
25
services/txnBot/tsconfig.json
Normal file
25
services/txnBot/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue