From 514be62cbb540602ed2f0b3ff4b8a01e34b7df4a Mon Sep 17 00:00:00 2001 From: johba Date: Sat, 4 Oct 2025 15:40:30 +0200 Subject: [PATCH] txnbot - rewrite and lint (#53) resolves #46 Co-authored-by: johba Reviewed-on: https://codeberg.org/johba/harb/pulls/53 --- .husky/pre-commit | 4 + containers/txn-bot-entrypoint.sh | 3 + kraiken-lib/package.json | 2 +- onchain/src/LiquidityManager.sol | 2 +- services/txnBot/.lintstagedrc.json | 6 + services/txnBot/.prettierrc | 8 + services/txnBot/README.md | 4 + services/txnBot/eslint.config.js | 65 ++++ services/txnBot/generateKey.js | 11 - services/txnBot/package.json | 27 +- services/txnBot/service.js | 414 --------------------- services/txnBot/src/generateKey.ts | 13 + services/txnBot/src/logger.ts | 56 +++ services/txnBot/src/recenterAccess.test.ts | 45 +++ services/txnBot/src/recenterAccess.ts | 40 ++ services/txnBot/src/service.ts | 388 +++++++++++++++++++ services/txnBot/src/types.ts | 48 +++ services/txnBot/tsconfig.build.json | 4 + services/txnBot/tsconfig.json | 25 ++ 19 files changed, 736 insertions(+), 429 deletions(-) create mode 100644 services/txnBot/.lintstagedrc.json create mode 100644 services/txnBot/.prettierrc create mode 100644 services/txnBot/eslint.config.js delete mode 100644 services/txnBot/generateKey.js delete mode 100644 services/txnBot/service.js create mode 100644 services/txnBot/src/generateKey.ts create mode 100644 services/txnBot/src/logger.ts create mode 100644 services/txnBot/src/recenterAccess.test.ts create mode 100644 services/txnBot/src/recenterAccess.ts create mode 100644 services/txnBot/src/service.ts create mode 100644 services/txnBot/src/types.ts create mode 100644 services/txnBot/tsconfig.build.json create mode 100644 services/txnBot/tsconfig.json diff --git a/.husky/pre-commit b/.husky/pre-commit index ffb4d11..4bba81c 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -10,6 +10,10 @@ if [ -d "kraiken-lib" ]; then (cd kraiken-lib && npx lint-staged) fi +if [ -d "services/txnBot" ]; then + (cd services/txnBot && npx lint-staged) +fi + if [ -d "web-app" ]; then (cd web-app && npx lint-staged) fi diff --git a/containers/txn-bot-entrypoint.sh b/containers/txn-bot-entrypoint.sh index aa71f3a..85f54d6 100755 --- a/containers/txn-bot-entrypoint.sh +++ b/containers/txn-bot-entrypoint.sh @@ -43,5 +43,8 @@ npm install --no-save --loglevel error 2>&1 || { npm install --force --no-save --loglevel error } +echo "[txn-bot-entrypoint] Building TypeScript..." +npm run build + export TXN_BOT_ENV_FILE="$TXNBOT_ENV_FILE" exec npm run start diff --git a/kraiken-lib/package.json b/kraiken-lib/package.json index 9a9d562..c7c8465 100644 --- a/kraiken-lib/package.json +++ b/kraiken-lib/package.json @@ -58,7 +58,7 @@ "lint:fix": "eslint 'src/**/*.ts' --fix", "format": "prettier --write 'src/**/*.ts'", "format:check": "prettier --check 'src/**/*.ts'", - "prepare": "husky install" + "prepare": "husky install || true" }, "lint-staged": { "src/**/*.ts": [ diff --git a/onchain/src/LiquidityManager.sol b/onchain/src/LiquidityManager.sol index 2b5d2d9..9ab8434 100644 --- a/onchain/src/LiquidityManager.sol +++ b/onchain/src/LiquidityManager.sol @@ -42,7 +42,7 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { PoolKey private poolKey; /// @notice Access control and fee management - address private recenterAccess; + address public recenterAccess; address public feeDestination; /// @notice Custom errors diff --git a/services/txnBot/.lintstagedrc.json b/services/txnBot/.lintstagedrc.json new file mode 100644 index 0000000..65d169d --- /dev/null +++ b/services/txnBot/.lintstagedrc.json @@ -0,0 +1,6 @@ +{ + "src/**/*.ts": [ + "eslint --fix", + "prettier --write" + ] +} diff --git a/services/txnBot/.prettierrc b/services/txnBot/.prettierrc new file mode 100644 index 0000000..ea38c91 --- /dev/null +++ b/services/txnBot/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "printWidth": 140, + "tabWidth": 2, + "trailingComma": "es5", + "arrowParens": "avoid" +} diff --git a/services/txnBot/README.md b/services/txnBot/README.md index ce6081a..3518d90 100644 --- a/services/txnBot/README.md +++ b/services/txnBot/README.md @@ -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. + +## 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. diff --git a/services/txnBot/eslint.config.js b/services/txnBot/eslint.config.js new file mode 100644 index 0000000..f05c097 --- /dev/null +++ b/services/txnBot/eslint.config.js @@ -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, +]; diff --git a/services/txnBot/generateKey.js b/services/txnBot/generateKey.js deleted file mode 100644 index f81e91c..0000000 --- a/services/txnBot/generateKey.js +++ /dev/null @@ -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); \ No newline at end of file diff --git a/services/txnBot/package.json b/services/txnBot/package.json index e24a053..b2c1856 100644 --- a/services/txnBot/package.json +++ b/services/txnBot/package.json @@ -2,15 +2,38 @@ "name": "marketMaker", "version": "0.0.1", "type": "module", - "main": "index.js", + "main": "dist/service.js", "license": "GPL3", "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": { "dotenv": "^16.4.5", "ethers": "^6.13.2", "express": "^5.0.0", "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" } } diff --git a/services/txnBot/service.js b/services/txnBot/service.js deleted file mode 100644 index 72f1463..0000000 --- a/services/txnBot/service.js +++ /dev/null @@ -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}`); -}); diff --git a/services/txnBot/src/generateKey.ts b/services/txnBot/src/generateKey.ts new file mode 100644 index 0000000..0ba73b1 --- /dev/null +++ b/services/txnBot/src/generateKey.ts @@ -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(); diff --git a/services/txnBot/src/logger.ts b/services/txnBot/src/logger.ts new file mode 100644 index 0000000..3479f4d --- /dev/null +++ b/services/txnBot/src/logger.ts @@ -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(); diff --git a/services/txnBot/src/recenterAccess.test.ts b/services/txnBot/src/recenterAccess.test.ts new file mode 100644 index 0000000..69867d3 --- /dev/null +++ b/services/txnBot/src/recenterAccess.test.ts @@ -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 { + 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); +}); diff --git a/services/txnBot/src/recenterAccess.ts b/services/txnBot/src/recenterAccess.ts new file mode 100644 index 0000000..d45dba9 --- /dev/null +++ b/services/txnBot/src/recenterAccess.ts @@ -0,0 +1,40 @@ +import { ethers } from 'ethers'; + +export interface RecenterAccessReader { + recenterAccess(): Promise; +} + +export async function readRecenterAccess(reader: RecenterAccessReader, zeroAddress: string): Promise { + 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; + } +} diff --git a/services/txnBot/src/service.ts b/services/txnBot/src/service.ts new file mode 100644 index 0000000..32efa63 --- /dev/null +++ b/services/txnBot/src/service.ts @@ -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 = { + 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 => { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`); +}); diff --git a/services/txnBot/src/types.ts b/services/txnBot/src/types.ts new file mode 100644 index 0000000..4943245 --- /dev/null +++ b/services/txnBot/src/types.ts @@ -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 }>; +} diff --git a/services/txnBot/tsconfig.build.json b/services/txnBot/tsconfig.build.json new file mode 100644 index 0000000..af8cdbb --- /dev/null +++ b/services/txnBot/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/services/txnBot/tsconfig.json b/services/txnBot/tsconfig.json new file mode 100644 index 0000000..7e78501 --- /dev/null +++ b/services/txnBot/tsconfig.json @@ -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"] +}