diff --git a/aerodrome_manager.py b/aerodrome_manager.py new file mode 100644 index 0000000..fed9802 --- /dev/null +++ b/aerodrome_manager.py @@ -0,0 +1,711 @@ +# --- AERODROME MANAGER (BASE CHAIN) --- +# Adapted from uniswap_manager.py for Aerodrome Slipstream on Base. +# Key Differences: +# 1. Base Chain RPC & Tokens (WETH/USDC) +# 2. Aerodrome Slipstream Contract Addresses +# 3. Dynamic Tick Spacing (vs hardcoded 10) +# 4. Separate Status File (aerodrome_status.json) + +import os +import sys +import time +import json +import re +import logging +import math +from decimal import Decimal, getcontext +from datetime import datetime +from typing import Optional, Dict, Tuple, Any, List + +from web3 import Web3 +from web3.exceptions import TimeExhausted, ContractLogicError +from eth_account import Account +from eth_account.signers.local import LocalAccount +from dotenv import load_dotenv + +# --- IMPORTS FOR KPI --- +try: + from tools.kpi_tracker import log_kpi_snapshot +except ImportError: + logging.warning("KPI Tracker not found. Performance logging disabled.") + log_kpi_snapshot = None + +# Set Decimal precision high enough for EVM math +getcontext().prec = 60 + +# --- LOGGING SETUP --- +current_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(current_dir) + +# Ensure logs directory exists +log_dir = os.path.join(current_dir, 'logs') +os.makedirs(log_dir, exist_ok=True) + +# Logger Name changed to avoid conflict +LOGGER_NAME = "AERODROME_MANAGER" + +try: + from logging_utils import setup_logging + logger = setup_logging("normal", LOGGER_NAME) +except ImportError: + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + logger = logging.getLogger(LOGGER_NAME) + +# Custom Filter for Millisecond Unix Timestamp +class UnixMsLogFilter(logging.Filter): + def filter(self, record): + record.unix_ms = int(record.created * 1000) + return True + +# Add File Handler +log_file = os.path.join(log_dir, 'aerodrome_manager.log') +file_handler = logging.FileHandler(log_file, encoding='utf-8') +file_handler.setLevel(logging.INFO) +file_handler.addFilter(UnixMsLogFilter()) +formatter = logging.Formatter('%(unix_ms)d, %(asctime)s - %(name)s - %(levelname)s - %(message)s') +file_handler.setFormatter(formatter) +logger.addHandler(file_handler) + +# --- ABIs (Standard V3/Slipstream Compatible) --- +NONFUNGIBLE_POSITION_MANAGER_ABI = json.loads(''' +[ + {"anonymous": false, "inputs": [{"indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"indexed": false, "internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"indexed": false, "internalType": "uint256", "name": "amount0", "type": "uint256"}, {"indexed": false, "internalType": "uint256", "name": "amount1", "type": "uint256"}], "name": "IncreaseLiquidity", "type": "event"}, + {"anonymous": false, "inputs": [{"indexed": true, "internalType": "address", "name": "from", "type": "address"}, {"indexed": true, "internalType": "address", "name": "to", "type": "address"}, {"indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256"}], "name": "Transfer", "type": "event"}, + {"inputs": [], "name": "factory", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, + {"inputs": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}], "name": "positions", "outputs": [{"internalType": "uint96", "name": "nonce", "type": "uint96"}, {"internalType": "address", "name": "operator", "type": "address"}, {"internalType": "address", "name": "token0", "type": "address"}, {"internalType": "address", "name": "token1", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}, {"internalType": "int24", "name": "tickLower", "type": "int24"}, {"internalType": "int24", "name": "tickUpper", "type": "int24"}, {"internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"internalType": "uint256", "name": "feeGrowthInside0LastX128", "type": "uint256"}, {"internalType": "uint256", "name": "feeGrowthInside1LastX128", "type": "uint256"}, {"internalType": "uint128", "name": "tokensOwed0", "type": "uint128"}, {"internalType": "uint128", "name": "tokensOwed1", "type": "uint128"}], "stateMutability": "view", "type": "function"}, + {"inputs": [{"components": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "address", "name": "recipient", "type": "address"}, {"internalType": "uint128", "name": "amount0Max", "type": "uint128"}, {"internalType": "uint128", "name": "amount1Max", "type": "uint128"}], "internalType": "struct INonfungiblePositionManager.CollectParams", "name": "params", "type": "tuple"}], "name": "collect", "outputs": [{"internalType": "uint256", "name": "amount0", "type": "uint256"}, {"internalType": "uint256", "name": "amount1", "type": "uint256"}], "stateMutability": "payable", "type": "function"}, + {"inputs": [{"components": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"internalType": "uint256", "name": "amount0Min", "type": "uint256"}, {"internalType": "uint256", "name": "amount1Min", "type": "uint256"}, {"internalType": "uint256", "name": "deadline", "type": "uint256"}], "internalType": "struct INonfungiblePositionManager.DecreaseLiquidityParams", "name": "params", "type": "tuple"}], "name": "decreaseLiquidity", "outputs": [{"internalType": "uint256", "name": "amount0", "type": "uint256"}, {"internalType": "uint256", "name": "amount1", "type": "uint256"}], "stateMutability": "payable", "type": "function"}, + {"inputs": [{"components": [{"internalType": "address", "name": "token0", "type": "address"}, {"internalType": "address", "name": "token1", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}, {"internalType": "int24", "name": "tickLower", "type": "int24"}, {"internalType": "int24", "name": "tickUpper", "type": "int24"}, {"internalType": "uint256", "name": "amount0Desired", "type": "uint256"}, {"internalType": "uint256", "name": "amount1Desired", "type": "uint256"}, {"internalType": "uint256", "name": "amount0Min", "type": "uint256"}, {"internalType": "uint256", "name": "amount1Min", "type": "uint256"}, {"internalType": "address", "name": "recipient", "type": "address"}, {"internalType": "uint256", "name": "deadline", "type": "uint256"}], "internalType": "struct INonfungiblePositionManager.MintParams", "name": "params", "type": "tuple"}], "name": "mint", "outputs": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"internalType": "uint256", "name": "amount0", "type": "uint256"}, {"internalType": "uint256", "name": "amount1", "type": "uint256"}], "stateMutability": "payable", "type": "function"} +] +''') + +UNISWAP_V3_POOL_ABI = json.loads(''' +[ + {"inputs": [], "name": "slot0", "outputs": [{"internalType": "uint160", "name": "sqrtPriceX96", "type": "uint160"}, {"internalType": "int24", "name": "tick", "type": "int24"}, {"internalType": "uint16", "name": "observationIndex", "type": "uint16"}, {"internalType": "uint16", "name": "observationCardinality", "type": "uint16"}, {"internalType": "uint16", "name": "observationCardinalityNext", "type": "uint16"}, {"internalType": "uint8", "name": "feeProtocol", "type": "uint8"}, {"internalType": "bool", "name": "unlocked", "type": "bool"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "token0", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "token1", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "fee", "outputs": [{"internalType": "uint24", "name": "", "type": "uint24"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "liquidity", "outputs": [{"internalType": "uint128", "name": "", "type": "uint128"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "tickSpacing", "outputs": [{"internalType": "int24", "name": "", "type": "int24"}], "stateMutability": "view", "type": "function"} +] +''') + +ERC20_ABI = json.loads(''' +[ + {"inputs": [], "name": "decimals", "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "symbol", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "view", "type": "function"}, + {"inputs": [{"internalType": "address", "name": "account", "type": "address"}], "name": "balanceOf", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, + {"inputs": [{"internalType": "address", "name": "spender", "type": "address"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "approve", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "nonpayable", "type": "function"}, + {"inputs": [{"internalType": "address", "name": "owner", "type": "address"}, {"internalType": "address", "name": "spender", "type": "address"}], "name": "allowance", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"} +] +''') + +UNISWAP_V3_FACTORY_ABI = json.loads(''' +[ + {"inputs": [{"internalType": "address", "name": "tokenA", "type": "address"}, {"internalType": "address", "name": "tokenB", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}], "name": "getPool", "outputs": [{"internalType": "address", "name": "pool", "type": "address"}], "stateMutability": "view", "type": "function"} +] +''') + +SWAP_ROUTER_ABI = json.loads(''' +[ + {"inputs": [{"components": [{"internalType": "address", "name": "tokenIn", "type": "address"}, {"internalType": "address", "name": "tokenOut", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}, {"internalType": "address", "name": "recipient", "type": "address"}, {"internalType": "uint256", "name": "deadline", "type": "uint256"}, {"internalType": "uint256", "name": "amountIn", "type": "uint256"}, {"internalType": "uint256", "name": "amountOutMinimum", "type": "uint256"}, {"internalType": "uint160", "name": "sqrtPriceLimitX96", "type": "uint160"}], "internalType": "struct ISwapRouter.ExactInputSingleParams", "name": "params", "type": "tuple"}], "name": "exactInputSingle", "outputs": [{"internalType": "uint256", "name": "amountOut", "type": "uint256"}], "stateMutability": "payable", "type": "function"} +] +''') + +WETH9_ABI = json.loads(''' +[ + {"constant": false, "inputs": [], "name": "deposit", "outputs": [], "payable": true, "stateMutability": "payable", "type": "function"}, + {"constant": false, "inputs": [{"name": "wad", "type": "uint256"}], "name": "withdraw", "outputs": [], "payable": false, "stateMutability": "nonpayable", "type": "function"} +] +''') + +# --- CONFIGURATION (BASE CHAIN / AERODROME) --- +# Aerodrome Slipstream Addresses (Base) +NONFUNGIBLE_POSITION_MANAGER_ADDRESS = "0x827922686190790b37229fd06084350e74485b72" +AERODROME_SWAP_ROUTER_ADDRESS = "0xBE6D8f0d05027F14e266dCC1E844717068f6f296" + +# Base Chain Tokens +WETH_ADDRESS = "0x4200000000000000000000000000000000000006" +USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + +STATUS_FILE = "aerodrome_status.json" # Separate status file for Base +MONITOR_INTERVAL_SECONDS = 666 +CLOSE_POSITION_ENABLED = True +OPEN_POSITION_ENABLED = True +REBALANCE_ON_CLOSE_BELOW_RANGE = True +TARGET_INVESTMENT_VALUE_USDC = 2000 +INITIAL_HEDGE_CAPITAL_USDC = 2000 +RANGE_WIDTH_PCT = Decimal("0.02") # +/- 1% +SLIPPAGE_TOLERANCE = Decimal("0.02") +TRANSACTION_TIMEOUT_SECONDS = 30 + +# --- HELPER FUNCTIONS --- + +def clean_address(addr: str) -> str: + """Ensure address is checksummed.""" + if not Web3.is_address(addr): + raise ValueError(f"Invalid address: {addr}") + return Web3.to_checksum_address(addr) + +def to_decimal(value: Any, decimals: int = 0) -> Decimal: + """Convert value to Decimal, optionally scaling down by decimals.""" + if isinstance(value, Decimal): + return value + return Decimal(value) / (Decimal(10) ** decimals) + +def to_wei_int(value: Decimal, decimals: int) -> int: + """Convert Decimal value to integer Wei representation.""" + return int(value * (Decimal(10) ** decimals)) + +def get_gas_params(w3: Web3) -> Dict[str, int]: + """Get dynamic gas parameters for EIP-1559 (Base Chain).""" + latest_block = w3.eth.get_block("latest") + base_fee = latest_block['baseFeePerGas'] + # Base chain fees are low, but we prioritize + max_priority_fee = w3.eth.max_priority_fee or Web3.to_wei(0.05, 'gwei') + + # Max Fee = Base Fee * 1.25 + Priority Fee + max_fee = int(base_fee * 1.25) + max_priority_fee + + return { + 'maxFeePerGas': max_fee, + 'maxPriorityFeePerGas': max_priority_fee + } + +def send_transaction_robust( + w3: Web3, + account: LocalAccount, + func_call: Any, + value: int = 0, + gas_limit: Optional[int] = None, + extra_msg: str = "" +) -> Optional[Any]: + try: + tx_params = { + 'from': account.address, + 'nonce': w3.eth.get_transaction_count(account.address), + 'value': value, + 'chainId': w3.eth.chain_id, + } + + gas_fees = get_gas_params(w3) + tx_params.update(gas_fees) + + try: + if hasattr(func_call, 'call'): + func_call.call({'from': account.address, 'value': value}) + estimated_gas = func_call.estimate_gas({'from': account.address, 'value': value}) + else: + estimated_gas = 200000 + + tx_params['gas'] = gas_limit if gas_limit else int(estimated_gas * 1.2) + + if hasattr(func_call, 'build_transaction'): + tx = func_call.build_transaction(tx_params) + else: + raise ValueError("Invalid function call object") + + except ContractLogicError as e: + logger.error(f"❌ Simulation/Estimation failed for {extra_msg}: {e}") + return None + + signed_tx = account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) + logger.info(f"📤 Sent {extra_msg} | Hash: {tx_hash.hex()}") + + receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=TRANSACTION_TIMEOUT_SECONDS) + + if receipt.status == 1: + logger.info(f"✅ Executed {extra_msg} | Block: {receipt.blockNumber}") + return receipt + else: + logger.error(f"❌ Transaction Reverted {extra_msg} | Hash: {tx_hash.hex()}") + return None + + except TimeExhausted: + logger.error(f"⌛ Transaction Timeout {extra_msg} - Check Mempool") + return None + except Exception as e: + logger.error(f"❌ Transaction Error {extra_msg}: {e}") + return None + +def price_from_sqrt_price_x96(sqrt_price_x96: int, token0_decimals: int, token1_decimals: int) -> Decimal: + sqrt_price = Decimal(sqrt_price_x96) + q96 = Decimal(2) ** 96 + price = (sqrt_price / q96) ** 2 + adjustment = Decimal(10) ** (token0_decimals - token1_decimals) + return price * adjustment + +def price_from_tick(tick: int, token0_decimals: int, token1_decimals: int) -> Decimal: + price = Decimal("1.0001") ** tick + adjustment = Decimal(10) ** (token0_decimals - token1_decimals) + return price * adjustment + +def get_sqrt_ratio_at_tick(tick: int) -> int: + return int((1.0001 ** (tick / 2)) * (2 ** 96)) + +def get_amounts_for_liquidity(sqrt_ratio_current: int, sqrt_ratio_a: int, sqrt_ratio_b: int, liquidity: int) -> Tuple[int, int]: + if sqrt_ratio_a > sqrt_ratio_b: + sqrt_ratio_a, sqrt_ratio_b = sqrt_ratio_b, sqrt_ratio_a + + amount0 = 0 + amount1 = 0 + Q96 = 1 << 96 + + if sqrt_ratio_current <= sqrt_ratio_a: + amount0 = (liquidity * Q96 // sqrt_ratio_a) - (liquidity * Q96 // sqrt_ratio_b) + amount1 = 0 + elif sqrt_ratio_current < sqrt_ratio_b: + amount0 = (liquidity * Q96 // sqrt_ratio_current) - (liquidity * Q96 // sqrt_ratio_b) + amount1 = (liquidity * (sqrt_ratio_current - sqrt_ratio_a)) // Q96 + else: + amount1 = (liquidity * (sqrt_ratio_b - sqrt_ratio_a)) // Q96 + amount0 = 0 + + return amount0, amount1 + +# --- CORE LOGIC --- + +def get_position_details(w3: Web3, npm_contract, factory_contract, token_id: int): + try: + position_data = npm_contract.functions.positions(token_id).call() + (nonce, operator, token0_address, token1_address, fee, tickLower, tickUpper, liquidity, + feeGrowthInside0, feeGrowthInside1, tokensOwed0, tokensOwed1) = position_data + + token0_contract = w3.eth.contract(address=token0_address, abi=ERC20_ABI) + token1_contract = w3.eth.contract(address=token1_address, abi=ERC20_ABI) + + token0_symbol = token0_contract.functions.symbol().call() + token1_symbol = token1_contract.functions.symbol().call() + token0_decimals = token0_contract.functions.decimals().call() + token1_decimals = token1_contract.functions.decimals().call() + + pool_address = factory_contract.functions.getPool(token0_address, token1_address, fee).call() + if pool_address == '0x0000000000000000000000000000000000000000': + return None, None + + pool_contract = w3.eth.contract(address=pool_address, abi=UNISWAP_V3_POOL_ABI) + + return { + "token0_address": token0_address, "token1_address": token1_address, + "token0_symbol": token0_symbol, "token1_symbol": token1_symbol, + "token0_decimals": token0_decimals, "token1_decimals": token1_decimals, + "fee": fee, "tickLower": tickLower, "tickUpper": tickUpper, "liquidity": liquidity, + "pool_address": pool_address + }, pool_contract + except Exception as e: + logger.error(f"❌ Error fetching position details for ID {token_id}: {e}") + return None, None + +def get_pool_dynamic_data(pool_contract) -> Optional[Dict[str, Any]]: + try: + slot0 = pool_contract.functions.slot0().call() + return {"sqrtPriceX96": slot0[0], "tick": slot0[1]} + except Exception as e: + logger.error(f"❌ Pool data fetch failed: {e}") + return None + +def calculate_mint_amounts(current_tick, tick_lower, tick_upper, investment_value_token1: Decimal, decimals0, decimals1, sqrt_price_current_x96) -> Tuple[int, int]: + sqrt_price_current = get_sqrt_ratio_at_tick(current_tick) + sqrt_price_lower = get_sqrt_ratio_at_tick(tick_lower) + sqrt_price_upper = get_sqrt_ratio_at_tick(tick_upper) + + price_t0_in_t1 = price_from_sqrt_price_x96(sqrt_price_current_x96, decimals0, decimals1) + + L_test = 1 << 128 + amt0_test_wei, amt1_test_wei = get_amounts_for_liquidity(sqrt_price_current, sqrt_price_lower, sqrt_price_upper, L_test) + + amt0_test = Decimal(amt0_test_wei) / Decimal(10**decimals0) + amt1_test = Decimal(amt1_test_wei) / Decimal(10**decimals1) + + value_test = (amt0_test * price_t0_in_t1) + amt1_test + + if value_test <= 0: + return 0, 0 + + scale = investment_value_token1 / value_test + + final_amt0_wei = int(Decimal(amt0_test_wei) * scale) + final_amt1_wei = int(Decimal(amt1_test_wei) * scale) + + return final_amt0_wei, final_amt1_wei + +def ensure_allowance(w3: Web3, account: LocalAccount, token_address: str, spender_address: str, amount_needed: int) -> bool: + try: + token_c = w3.eth.contract(address=token_address, abi=ERC20_ABI) + allowance = token_c.functions.allowance(account.address, spender_address).call() + + if allowance >= amount_needed: + return True + + logger.info(f"🔓 Approving {token_address} for {spender_address}...") + + if allowance > 0: + send_transaction_robust(w3, account, token_c.functions.approve(spender_address, 0), extra_msg="Reset Allowance") + + receipt = send_transaction_robust( + w3, account, + token_c.functions.approve(spender_address, amount_needed), + extra_msg=f"Approve {token_address}" + ) + return receipt is not None + + except Exception as e: + logger.error(f"❌ Allowance check/approve failed: {e}") + return False + +def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount, token0: str, token1: str, amount0_needed: int, amount1_needed: int, sqrt_price_x96: int, d0: int, d1: int) -> bool: + token0 = clean_address(token0) + token1 = clean_address(token1) + token0_c = w3.eth.contract(address=token0, abi=ERC20_ABI) + token1_c = w3.eth.contract(address=token1, abi=ERC20_ABI) + + bal0 = token0_c.functions.balanceOf(account.address).call() + bal1 = token1_c.functions.balanceOf(account.address).call() + + deficit0 = max(0, amount0_needed - bal0) + deficit1 = max(0, amount1_needed - bal1) + + weth_lower = WETH_ADDRESS.lower() + + # Auto-wrap ETH on Base + if (deficit0 > 0 and token0.lower() == weth_lower) or (deficit1 > 0 and token1.lower() == weth_lower): + eth_bal = w3.eth.get_balance(account.address) + gas_reserve = Web3.to_wei(0.005, 'ether') # Lower gas reserve for Base + available_eth = max(0, eth_bal - gas_reserve) + + wrap_needed = 0 + if token0.lower() == weth_lower: wrap_needed += deficit0 + if token1.lower() == weth_lower: wrap_needed += deficit1 + + amount_to_wrap = min(wrap_needed, available_eth) + + if amount_to_wrap > 0: + logger.info(f"🌯 Wrapping {Web3.from_wei(amount_to_wrap, 'ether')} ETH...") + weth_c = w3.eth.contract(address=WETH_ADDRESS, abi=WETH9_ABI) + receipt = send_transaction_robust(w3, account, weth_c.functions.deposit(), value=amount_to_wrap, extra_msg="Wrap ETH") + if receipt: + bal0 = token0_c.functions.balanceOf(account.address).call() + bal1 = token1_c.functions.balanceOf(account.address).call() + deficit0 = max(0, amount0_needed - bal0) + deficit1 = max(0, amount1_needed - bal1) + + if deficit0 == 0 and deficit1 == 0: + return True + + price_0_in_1 = price_from_sqrt_price_x96(sqrt_price_x96, d0, d1) + + token_in, token_out = None, None + amount_in = 0 + buffer_multiplier = Decimal("1.02") + + if deficit0 > 0 and bal1 > amount1_needed: + cost_in_t1 = Decimal(deficit0) / Decimal(10**d0) * price_0_in_1 + amount_in_needed = int(cost_in_t1 * Decimal(10**d1) * buffer_multiplier) + surplus1 = bal1 - amount1_needed + if surplus1 >= amount_in_needed: + token_in, token_out = token1, token0 + amount_in = amount_in_needed + logger.info(f"🧮 Calc: Need {deficit0} T0. Cost ~{amount_in_needed} T1. Surplus: {surplus1}") + else: + logger.warning(f"❌ Insufficient Surplus T1. Need {amount_in_needed}, Have {surplus1}") + + elif deficit1 > 0 and bal0 > amount0_needed: + if price_0_in_1 > 0: + cost_in_t0 = (Decimal(deficit1) / Decimal(10**d1)) / price_0_in_1 + amount_in_needed = int(cost_in_t0 * Decimal(10**d0) * buffer_multiplier) + surplus0 = bal0 - amount0_needed + if surplus0 >= amount_in_needed: + token_in, token_out = token0, token1 + amount_in = amount_in_needed + logger.info(f"🧮 Calc: Need {deficit1} T1. Cost ~{amount_in_needed} T0. Surplus: {surplus0}") + else: + logger.warning(f"❌ Insufficient Surplus T0. Need {amount_in_needed}, Have {surplus0}") + + if token_in and amount_in > 0: + logger.info(f"🔄 Swapping {amount_in} {token_in} to cover deficit...") + + if not ensure_allowance(w3, account, token_in, AERODROME_SWAP_ROUTER_ADDRESS, amount_in): + return False + + params = ( + token_in, token_out, 500, account.address, + int(time.time()) + 120, + amount_in, + 0, + 0 + ) + + receipt = send_transaction_robust(w3, account, router_contract.functions.exactInputSingle(params), extra_msg="Swap Surplus") + if receipt: + bal0 = token0_c.functions.balanceOf(account.address).call() + bal1 = token1_c.functions.balanceOf(account.address).call() + if bal0 >= amount0_needed and bal1 >= amount1_needed: + return True + else: + logger.warning(f"⚠️ Swap executed but still short? T0: {bal0}/{amount0_needed}, T1: {bal1}/{amount1_needed}") + return False + + logger.warning(f"❌ Insufficient funds. T0: {bal0}/{amount0_needed}, T1: {bal1}/{amount1_needed}") + return False + +def mint_new_position(w3: Web3, npm_contract, account: LocalAccount, token0: str, token1: str, amount0: int, amount1: int, tick_lower: int, tick_upper: int) -> Optional[Dict]: + logger.info("🚀 Minting new position on Aerodrome...") + + if not ensure_allowance(w3, account, token0, NONFUNGIBLE_POSITION_MANAGER_ADDRESS, amount0): return None + if not ensure_allowance(w3, account, token1, NONFUNGIBLE_POSITION_MANAGER_ADDRESS, amount1): return None + + amount0_min = int(Decimal(amount0) * (Decimal(1) - SLIPPAGE_TOLERANCE)) + amount1_min = int(Decimal(amount1) * (Decimal(1) - SLIPPAGE_TOLERANCE)) + + params = ( + token0, token1, 500, + tick_lower, tick_upper, + amount0, amount1, + amount0_min, amount1_min, + account.address, + int(time.time()) + 180 + ) + + receipt = send_transaction_robust(w3, account, npm_contract.functions.mint(params), extra_msg="Mint Position") + + if receipt and receipt.status == 1: + try: + transfer_topic = Web3.keccak(text="Transfer(address,address,uint256)").hex() + increase_liq_topic = Web3.keccak(text="IncreaseLiquidity(uint256,uint128,uint256,uint256)").hex() + + minted_data = {'token_id': None, 'amount0': 0, 'amount1': 0} + + for log in receipt.logs: + topics = [t.hex() for t in log['topics']] + if topics[0] == transfer_topic: + if "0000000000000000000000000000000000000000" in topics[1]: + minted_data['token_id'] = int(topics[3], 16) + if topics[0] == increase_liq_topic: + data = log['data'].hex() + if data.startswith('0x'): data = data[2:] + minted_data['amount0'] = int(data[64:128], 16) + minted_data['amount1'] = int(data[128:192], 16) + + if minted_data['token_id']: + d0, d1 = 18, 6 + fmt_amt0 = Decimal(minted_data['amount0']) / Decimal(10**d0) + fmt_amt1 = Decimal(minted_data['amount1']) / Decimal(10**d1) + + logger.info(f"✅ POSITION OPENED | ID: {minted_data['token_id']} | Deposited: {fmt_amt0:.6f} WETH + {fmt_amt1:.2f} USDC") + return minted_data + + except Exception as e: + logger.warning(f"Minted but failed to parse details: {e}") + + return None + +def decrease_liquidity(w3: Web3, npm_contract, account: LocalAccount, token_id: int, liquidity: int) -> bool: + if liquidity == 0: return True + + logger.info(f"📉 Decreasing Liquidity for {token_id}...") + + params = ( + token_id, + liquidity, + 0, 0, + int(time.time()) + 180 + ) + + receipt = send_transaction_robust(w3, account, npm_contract.functions.decreaseLiquidity(params), extra_msg=f"Decrease Liq {token_id}") + + if receipt and receipt.status == 1: + try: + decrease_topic = Web3.keccak(text="DecreaseLiquidity(uint256,uint128,uint256,uint256)").hex() + amt0, amt1 = 0, 0 + for log in receipt.logs: + topics = [t.hex() for t in log['topics']] + if topics[0] == decrease_topic and int(topics[1], 16) == token_id: + data = log['data'].hex()[2:] + amt0 = int(data[64:128], 16) + amt1 = int(data[128:192], 16) + break + + d0, d1 = 18, 6 + fmt_amt0 = Decimal(amt0) / Decimal(10**d0) + fmt_amt1 = Decimal(amt1) / Decimal(10**d1) + logger.info(f"📉 POSITION CLOSED | ID: {token_id} | Withdrawn: {fmt_amt0:.6f} WETH + {fmt_amt1:.2f} USDC") + except Exception as e: + logger.warning(f"Closed but failed to parse details: {e}") + return True + return False + +def collect_fees(w3: Web3, npm_contract, account: LocalAccount, token_id: int) -> bool: + logger.info(f"💰 Collecting Fees for {token_id}...") + max_val = 2**128 - 1 + params = (token_id, account.address, max_val, max_val) + receipt = send_transaction_robust(w3, account, npm_contract.functions.collect(params), extra_msg=f"Collect Fees {token_id}") + return receipt is not None + +def load_status_data() -> List[Dict]: + if not os.path.exists(STATUS_FILE): return [] + try: + with open(STATUS_FILE, 'r') as f: return json.load(f) + except: return [] + +def save_status_data(data: List[Dict]): + with open(STATUS_FILE, 'w') as f: json.dump(data, f, indent=2) + +def update_position_status(token_id: int, status: str, extra_data: Dict = {}): + data = load_status_data() + entry = next((item for item in data if item.get('token_id') == token_id), None) + + if not entry: + if status in ["OPEN", "PENDING_HEDGE"]: + entry = {"type": "AUTOMATIC", "token_id": token_id} + data.append(entry) + else: return + + entry['status'] = status + entry.update(extra_data) + if status == "CLOSED": + entry['timestamp_close'] = int(time.time()) + save_status_data(data) + logger.info(f"💾 Updated Position {token_id} status to {status}") + +def main(): + logger.info("🔷 Aerodrome Manager (Base Chain) Starting...") + load_dotenv(override=True) + + rpc_url = os.environ.get("BASE_RPC_URL") # UPDATED ENV VAR + private_key = os.environ.get("MAIN_WALLET_PRIVATE_KEY") + + if not rpc_url or not private_key: + logger.error("❌ Missing BASE_RPC_URL or MAIN_WALLET_PRIVATE_KEY in .env") + return + + w3 = Web3(Web3.HTTPProvider(rpc_url)) + if not w3.is_connected(): + logger.error("❌ Could not connect to Base RPC") + return + + account = Account.from_key(private_key) + logger.info(f"👤 Wallet: {account.address}") + + npm = w3.eth.contract(address=clean_address(NONFUNGIBLE_POSITION_MANAGER_ADDRESS), abi=NONFUNGIBLE_POSITION_MANAGER_ABI) + factory_addr = npm.functions.factory().call() + factory = w3.eth.contract(address=factory_addr, abi=UNISWAP_V3_FACTORY_ABI) + router = w3.eth.contract(address=clean_address(AERODROME_SWAP_ROUTER_ADDRESS), abi=SWAP_ROUTER_ABI) + + while True: + try: + status_data = load_status_data() + open_positions = [p for p in status_data if p.get('status') == 'OPEN'] + active_auto_pos = next((p for p in open_positions if p.get('type') == 'AUTOMATIC'), None) + + if active_auto_pos: + token_id = active_auto_pos['token_id'] + pos_details, pool_c = get_position_details(w3, npm, factory, token_id) + + if pos_details: + pool_data = get_pool_dynamic_data(pool_c) + current_tick = pool_data['tick'] + tick_lower = pos_details['tickLower'] + tick_upper = pos_details['tickUpper'] + in_range = tick_lower <= current_tick < tick_upper + + current_price = price_from_tick(current_tick, pos_details['token0_decimals'], pos_details['token1_decimals']) + lower_price = price_from_tick(tick_lower, pos_details['token0_decimals'], pos_details['token1_decimals']) + upper_price = price_from_tick(tick_upper, pos_details['token0_decimals'], pos_details['token1_decimals']) + + status_msg = "✅ IN RANGE" if in_range else "⚠️ OUT OF RANGE" + + # Simulated Fees + unclaimed0, unclaimed1, total_fees_usd = 0, 0, 0 + try: + fees_sim = npm.functions.collect((token_id, "0x0000000000000000000000000000000000000000", 2**128-1, 2**128-1)).call({'from': account.address}) + unclaimed0 = to_decimal(fees_sim[0], pos_details['token0_decimals']) + unclaimed1 = to_decimal(fees_sim[1], pos_details['token1_decimals']) + total_fees_usd = (unclaimed0 * current_price) + unclaimed1 + except: pass + + # PnL Calc + initial_value = Decimal(str(active_auto_pos.get('target_value', 0))) + curr_amt0_wei, curr_amt1_wei = get_amounts_for_liquidity( + pool_data['sqrtPriceX96'], + get_sqrt_ratio_at_tick(tick_lower), + get_sqrt_ratio_at_tick(tick_upper), + pos_details['liquidity'] + ) + curr_amt0 = Decimal(curr_amt0_wei) / Decimal(10**pos_details['token0_decimals']) + curr_amt1 = Decimal(curr_amt1_wei) / Decimal(10**pos_details['token1_decimals']) + current_pos_value_usd = (curr_amt0 * current_price) + curr_amt1 + + pnl_unrealized = current_pos_value_usd - initial_value + total_pnl_usd = pnl_unrealized + total_fees_usd + + logger.info(f"Position {token_id}: {status_msg} | Price: {current_price:.4f} [{lower_price:.4f} - {upper_price:.4f}] | TotPnL: ${total_pnl_usd:.2f} (Fees: ${total_fees_usd:.2f})") + + if not in_range and CLOSE_POSITION_ENABLED: + logger.warning(f"🛑 Closing Position {token_id} (Out of Range)") + update_position_status(token_id, "CLOSING") + if decrease_liquidity(w3, npm, account, token_id, pos_details['liquidity']): + collect_fees(w3, npm, account, token_id) + update_position_status(token_id, "CLOSED") + + elif OPEN_POSITION_ENABLED: + logger.info("🔍 No active position. Analyzing Aerodrome market...") + + token0 = clean_address(WETH_ADDRESS) + token1 = clean_address(USDC_ADDRESS) + fee = 500 # 0.05% fee tier on Aerodrome Slipstream WETH/USDC + + pool_addr = factory.functions.getPool(token0, token1, fee).call() + pool_c = w3.eth.contract(address=pool_addr, abi=UNISWAP_V3_POOL_ABI) + pool_data = get_pool_dynamic_data(pool_c) + + if pool_data: + tick = pool_data['tick'] + # Get Tick Spacing dynamically + tick_spacing = pool_c.functions.tickSpacing().call() + + tick_delta = int(math.log(1 + float(RANGE_WIDTH_PCT)) / math.log(1.0001)) + + tick_lower = (tick - tick_delta) // tick_spacing * tick_spacing + tick_upper = (tick + tick_delta) // tick_spacing * tick_spacing + + d0, d1 = 18, 6 + investment_val_dec = Decimal(str(TARGET_INVESTMENT_VALUE_USDC)) + + amt0, amt1 = calculate_mint_amounts(tick, tick_lower, tick_upper, investment_val_dec, d0, d1, pool_data['sqrtPriceX96']) + + if check_and_swap_for_deposit(w3, router, account, token0, token1, amt0, amt1, pool_data['sqrtPriceX96'], d0, d1): + minted = mint_new_position(w3, npm, account, token0, token1, amt0, amt1, tick_lower, tick_upper) + if minted: + entry_price = float(price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1)) + fmt_amt0 = float(Decimal(minted['amount0']) / Decimal(10**d0)) + fmt_amt1 = float(Decimal(minted['amount1']) / Decimal(10**d1)) + actual_value = (fmt_amt0 * entry_price) + fmt_amt1 + + new_position_data = { + "type": "AUTOMATIC", + "target_value": round(float(actual_value), 2), + "entry_price": round(entry_price, 2), + "amount0_initial": round(fmt_amt0, 4), + "amount1_initial": round(fmt_amt1, 2), + "range_upper": round(float(price_from_tick(tick_upper, d0, d1)), 2), + "range_lower": round(float(price_from_tick(tick_lower, d0, d1)), 2), + "timestamp_open": int(time.time()) + } + update_position_status(minted['token_id'], "OPEN", new_position_data) + + sleep_time = MONITOR_INTERVAL_SECONDS if active_auto_pos else 37 + time.sleep(sleep_time) + + except KeyboardInterrupt: + logger.info("👋 Exiting...") + break + except Exception as e: + logger.error(f"❌ Main Loop Error: {e}") + time.sleep(60) + +if __name__ == "__main__": + main()