|
|
@@ -0,0 +1,251 @@
|
|
|
+import { CONFIG } from '../config/index.js';
|
|
|
+import { PositionCache, logger, sleep, formatUsd } from '../utils/index.js';
|
|
|
+import { ByrealAPI, JupiterSwapper } from '../services/index.js';
|
|
|
+
|
|
|
+export class SniperEngine {
|
|
|
+ constructor() {
|
|
|
+ this.copiedCache = new PositionCache('.copied-positions.json', CONFIG.DATA_DIR);
|
|
|
+ this.closedCache = new PositionCache('.closed-positions.json', CONFIG.DATA_DIR);
|
|
|
+ this.swapper = new JupiterSwapper();
|
|
|
+ this.isRunning = false;
|
|
|
+ this.myWallet = CONFIG.MY_WALLET || this.swapper.getWalletAddress();
|
|
|
+ }
|
|
|
+
|
|
|
+ async analyzePosition(position, poolInfo) {
|
|
|
+ const decimalsA = poolInfo.mintA?.decimals || 6;
|
|
|
+ const decimalsB = poolInfo.mintB?.decimals || 6;
|
|
|
+ const currentPrice = parseFloat(poolInfo.mintB?.price || 0);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const lowerPrice = await ByrealAPI.getPriceFromTick(position.lowerTick, decimalsA, decimalsB);
|
|
|
+ const upperPrice = await ByrealAPI.getPriceFromTick(position.upperTick, decimalsA, decimalsB);
|
|
|
+
|
|
|
+ const min = Math.min(lowerPrice, upperPrice);
|
|
|
+ const max = Math.max(lowerPrice, upperPrice);
|
|
|
+
|
|
|
+ logger.info(`Price range: ${min.toFixed(6)} - ${max.toFixed(6)} (current: ${currentPrice.toFixed(6)})`);
|
|
|
+
|
|
|
+ return {
|
|
|
+ inRange: currentPrice >= min && currentPrice <= max,
|
|
|
+ lowerPrice,
|
|
|
+ upperPrice,
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ logger.error(`Error analyzing position: ${error.message}`);
|
|
|
+ return { inRange: false };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async executeCopy(position, poolInfo) {
|
|
|
+ const targetUsdValue = parseFloat(position.totalUsdValue || position.liquidityUsd || 0);
|
|
|
+
|
|
|
+ if (targetUsdValue <= 0) {
|
|
|
+ logger.warn('Position has no USD value, skipping');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ const copyUsdValue = Math.min(
|
|
|
+ targetUsdValue * CONFIG.COPY_MULTIPLIER,
|
|
|
+ CONFIG.MAX_USD_VALUE
|
|
|
+ );
|
|
|
+
|
|
|
+ if (copyUsdValue < CONFIG.MIN_USD_VALUE) {
|
|
|
+ logger.warn(`Position value ${formatUsd(copyUsdValue)} below minimum threshold`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ logger.info(`Copying position with ${CONFIG.COPY_MULTIPLIER}x multiplier`);
|
|
|
+ logger.info(`Target: ${formatUsd(targetUsdValue)} → Copy: ${formatUsd(copyUsdValue)}`);
|
|
|
+
|
|
|
+ const mintA = poolInfo.mintA?.address;
|
|
|
+ const mintB = poolInfo.mintB?.address;
|
|
|
+
|
|
|
+ if (!mintA || !mintB) {
|
|
|
+ logger.error('Pool info missing token addresses');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Get token prices and prepare swap
|
|
|
+ const tokenPrices = await this.swapper.getTokenPrices([mintA, mintB]);
|
|
|
+ const priceA = tokenPrices[mintA]?.price || 1;
|
|
|
+ const priceB = tokenPrices[mintB]?.price || 1;
|
|
|
+
|
|
|
+ const halfUsd = copyUsdValue / 2;
|
|
|
+ const amountA = halfUsd / priceA;
|
|
|
+ const amountB = halfUsd / priceB;
|
|
|
+
|
|
|
+ logger.info(`Estimated token needs: ${amountA.toFixed(4)} TokenA, ${amountB.toFixed(4)} TokenB`);
|
|
|
+
|
|
|
+ // Check and swap tokens
|
|
|
+ const tokenABalance = await this.swapper.getTokenBalance(mintA);
|
|
|
+ if (tokenABalance < amountA * 0.95) {
|
|
|
+ const success = await this.swapper.swapIfNeeded(
|
|
|
+ 'So11111111111111111111111111111111111111112',
|
|
|
+ mintA,
|
|
|
+ amountA,
|
|
|
+ poolInfo.mintA?.decimals || 6
|
|
|
+ );
|
|
|
+ if (!success) {
|
|
|
+ logger.error('Failed to acquire sufficient Token A');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const tokenBBalance = await this.swapper.getTokenBalance(mintB);
|
|
|
+ if (tokenBBalance < amountB * 0.95) {
|
|
|
+ const success = await this.swapper.swapIfNeeded(
|
|
|
+ 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
|
|
|
+ mintB,
|
|
|
+ amountB,
|
|
|
+ poolInfo.mintB?.decimals || 6
|
|
|
+ );
|
|
|
+ if (!success) {
|
|
|
+ logger.error('Failed to acquire sufficient Token B');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Execute copy
|
|
|
+ const success = await ByrealAPI.copyPosition(
|
|
|
+ position.nftMintAddress,
|
|
|
+ position.positionAddress,
|
|
|
+ copyUsdValue
|
|
|
+ );
|
|
|
+
|
|
|
+ if (success) {
|
|
|
+ this.copiedCache.add(position.positionAddress, {
|
|
|
+ poolAddress: position.poolAddress,
|
|
|
+ targetUsdValue,
|
|
|
+ copiedAt: new Date().toISOString(),
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return success;
|
|
|
+ }
|
|
|
+
|
|
|
+ async scanForNewPositions() {
|
|
|
+ logger.info(`Scanning for target wallet positions: ${CONFIG.TARGET_WALLET}`);
|
|
|
+
|
|
|
+ const { positions, poolMap } = await ByrealAPI.fetchTargetPositions(CONFIG.TARGET_WALLET);
|
|
|
+
|
|
|
+ if (positions.length === 0) {
|
|
|
+ logger.info('No active positions found for target wallet');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ logger.info(`Found ${positions.length} positions, analyzing...`);
|
|
|
+
|
|
|
+ for (const position of positions) {
|
|
|
+ const positionAddress = position.positionAddress;
|
|
|
+
|
|
|
+ if (this.copiedCache.has(positionAddress) || this.closedCache.has(positionAddress)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const poolInfo = poolMap[position.poolAddress];
|
|
|
+ if (!poolInfo) {
|
|
|
+ logger.warn(`Pool info not found for ${position.poolAddress}`);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ logger.info(`\nAnalyzing position: ${positionAddress}`);
|
|
|
+ logger.info(`Pool: ${poolInfo.mintA?.symbol || '?'}/${poolInfo.mintB?.symbol || '?'}`);
|
|
|
+
|
|
|
+ const analysis = await this.analyzePosition(position, poolInfo);
|
|
|
+
|
|
|
+ if (analysis.inRange) {
|
|
|
+ logger.success('Position is IN RANGE - ready to copy!');
|
|
|
+
|
|
|
+ const isCopied = await ByrealAPI.checkIfCopied(
|
|
|
+ position.poolAddress,
|
|
|
+ positionAddress,
|
|
|
+ this.myWallet
|
|
|
+ );
|
|
|
+
|
|
|
+ if (isCopied) {
|
|
|
+ logger.info('Already copied (confirmed via API)');
|
|
|
+ this.copiedCache.add(positionAddress, { poolAddress: position.poolAddress });
|
|
|
+ } else {
|
|
|
+ await this.executeCopy(position, poolInfo);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ logger.info('Position is OUT OF RANGE');
|
|
|
+ }
|
|
|
+
|
|
|
+ await sleep(500);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async scanForClosedPositions() {
|
|
|
+ logger.info('Checking for closed positions to close...');
|
|
|
+
|
|
|
+ const { positions } = await ByrealAPI.fetchTargetPositions(CONFIG.TARGET_WALLET);
|
|
|
+ const targetPositionAddresses = new Set(positions.map(p => p.positionAddress));
|
|
|
+
|
|
|
+ const myCopiedPositions = this.copiedCache.getAll();
|
|
|
+
|
|
|
+ for (const positionAddress of myCopiedPositions.positions) {
|
|
|
+ if (!targetPositionAddresses.has(positionAddress) && !this.closedCache.has(positionAddress)) {
|
|
|
+ logger.warn(`Target closed position: ${positionAddress}`);
|
|
|
+ logger.info('Closing my copy...');
|
|
|
+
|
|
|
+ const success = await ByrealAPI.closePosition(positionAddress);
|
|
|
+
|
|
|
+ if (success) {
|
|
|
+ this.closedCache.add(positionAddress, {
|
|
|
+ closedAt: new Date().toISOString(),
|
|
|
+ reason: 'Target closed position',
|
|
|
+ });
|
|
|
+ this.copiedCache.remove(positionAddress);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async start() {
|
|
|
+ if (this.isRunning) {
|
|
|
+ logger.warn('Sniper is already running');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.isRunning = true;
|
|
|
+
|
|
|
+ logger.info('═══════════════════════════════════════════');
|
|
|
+ logger.info('🚀 Byreal Sniper Bot Started');
|
|
|
+ logger.info(`🎯 Target: ${CONFIG.TARGET_WALLET}`);
|
|
|
+ logger.info(`💼 My Wallet: ${this.myWallet}`);
|
|
|
+ logger.info(`📈 Copy Multiplier: ${CONFIG.COPY_MULTIPLIER}x`);
|
|
|
+ logger.info(`💰 Max Position: ${formatUsd(CONFIG.MAX_USD_VALUE)}`);
|
|
|
+ logger.info(`⏱️ Poll Interval: ${CONFIG.POLL_INTERVAL_MS / 1000}s`);
|
|
|
+ logger.info('═══════════════════════════════════════════\n');
|
|
|
+
|
|
|
+ while (this.isRunning) {
|
|
|
+ try {
|
|
|
+ await this.scanForNewPositions();
|
|
|
+ await this.scanForClosedPositions();
|
|
|
+
|
|
|
+ logger.info(`\nSleeping for ${CONFIG.POLL_INTERVAL_MS / 1000} seconds...\n`);
|
|
|
+ await sleep(CONFIG.POLL_INTERVAL_MS);
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('Main loop error:', error.message);
|
|
|
+ await sleep(CONFIG.POLL_INTERVAL_MS);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ stop() {
|
|
|
+ logger.info('Stopping sniper bot...');
|
|
|
+ this.isRunning = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ getStatus() {
|
|
|
+ return {
|
|
|
+ isRunning: this.isRunning,
|
|
|
+ copiedPositions: this.copiedCache.getAll(),
|
|
|
+ closedPositions: this.closedCache.getAll(),
|
|
|
+ walletAddress: this.myWallet,
|
|
|
+ };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export default SniperEngine;
|