Add aerodrome_manager.py for Base Chain automation

This commit is contained in:
2025-12-22 14:31:46 +01:00
parent b3cdf98161
commit 22c92cb3ca

711
aerodrome_manager.py Normal file
View File

@ -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()