459 lines
22 KiB
Python
459 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Fee Collection & Position Recovery Script
|
|
Collects all accumulated fees and handles stuck positions
|
|
|
|
Features:
|
|
- Collects fees from all positions (OPEN, CLOSING, etc.)
|
|
- Recovers stuck positions with timeout transactions
|
|
- Handles zero liquidity positions
|
|
- Enhanced gas settings for reliability
|
|
- Detailed logging and status reporting
|
|
|
|
Usage:
|
|
python collect_fees.py
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import time
|
|
from datetime import datetime
|
|
|
|
# Required libraries
|
|
try:
|
|
from web3 import Web3
|
|
from eth_account import Account
|
|
except ImportError as e:
|
|
print(f"[ERROR] Missing required library: {e}")
|
|
print("Please install with: pip install web3 eth-account python-dotenv")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
from dotenv import load_dotenv
|
|
except ImportError:
|
|
print("[WARNING] python-dotenv not found, using environment variables directly")
|
|
def load_dotenv(override=True):
|
|
pass
|
|
|
|
def setup_logging():
|
|
"""Setup logging for fee collection"""
|
|
import logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
handlers=[
|
|
logging.StreamHandler(),
|
|
logging.FileHandler('collect_fees.log', encoding='utf-8')
|
|
]
|
|
)
|
|
return logging.getLogger(__name__)
|
|
|
|
logger = setup_logging()
|
|
|
|
# --- Contract ABIs ---
|
|
NONFUNGIBLE_POSITION_MANAGER_ABI = json.loads('''
|
|
[
|
|
{"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"}
|
|
]
|
|
''')
|
|
|
|
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"}
|
|
]
|
|
''')
|
|
|
|
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"}
|
|
]
|
|
''')
|
|
|
|
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"}
|
|
]
|
|
''')
|
|
|
|
# --- Contract Addresses ---
|
|
NONFUNGIBLE_POSITION_MANAGER_ADDRESS = "0xC36442b4a4522E871399CD71a7BDD847Ab11FE88"
|
|
WETH_ADDRESS = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"
|
|
USDC_ADDRESS = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"
|
|
|
|
def load_status_file():
|
|
"""Load hedge status file"""
|
|
status_file = "hedge_status.json"
|
|
if not os.path.exists(status_file):
|
|
logger.error(f"Status file {status_file} not found")
|
|
return []
|
|
|
|
try:
|
|
with open(status_file, 'r') as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
logger.error(f"Error loading status file: {e}")
|
|
return []
|
|
|
|
def update_position_status(token_id, new_status):
|
|
"""Update position status in status file"""
|
|
try:
|
|
current_data = load_status_file()
|
|
|
|
for position in current_data:
|
|
if position.get('token_id') == token_id:
|
|
old_status = position.get('status', 'UNKNOWN')
|
|
position['status'] = new_status
|
|
position['timestamp_close'] = int(time.time()) if new_status == 'CLOSED' else None
|
|
|
|
with open('hedge_status.json', 'w') as f:
|
|
json.dump(current_data, f, indent=2)
|
|
|
|
logger.info(f"Updated Position {token_id}: {old_status} -> {new_status}")
|
|
return True
|
|
|
|
logger.warning(f"Position {token_id} not found in status file")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error updating position status: {e}")
|
|
return False
|
|
|
|
def from_wei(amount, decimals):
|
|
"""Convert wei to human readable amount"""
|
|
return amount / (10**decimals)
|
|
|
|
def get_position_details(w3, npm_contract, token_id):
|
|
"""Get detailed position information"""
|
|
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
|
|
|
|
# Get token details
|
|
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()
|
|
|
|
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,
|
|
"tokensOwed0": tokensOwed0,
|
|
"tokensOwed1": tokensOwed1
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error getting position {token_id} details: {e}")
|
|
return None
|
|
|
|
def simulate_fees(w3, npm_contract, token_id):
|
|
"""Simulate fee collection to get amounts without executing"""
|
|
try:
|
|
result = npm_contract.functions.collect(
|
|
(token_id, "0x0000000000000000000000000000000000000000000", 2**128-1, 2**128-1)
|
|
).call()
|
|
return result[0], result[1] # amount0, amount1
|
|
except Exception as e:
|
|
logger.error(f"Error simulating fees for position {token_id}: {e}")
|
|
return 0, 0
|
|
|
|
def collect_fees(w3, npm_contract, account, token_id, max_retries=3):
|
|
"""Collect fees from a position with retry logic"""
|
|
for attempt in range(max_retries):
|
|
try:
|
|
logger.info(f"Attempt {attempt + 1}: Collecting fees from position {token_id}")
|
|
|
|
# Build collect transaction
|
|
txn = npm_contract.functions.collect(
|
|
(token_id, account.address, 2**128-1, 2**128-1)
|
|
).build_transaction({
|
|
'from': account.address,
|
|
'nonce': w3.eth.get_transaction_count(account.address),
|
|
'gas': 200000, # Higher gas limit for safety
|
|
'maxFeePerGas': w3.eth.gas_price * 3, # 3x gas price
|
|
'maxPriorityFeePerGas': w3.eth.max_priority_fee * 2,
|
|
'chainId': w3.eth.chain_id
|
|
})
|
|
|
|
# Sign and send
|
|
signed_txn = w3.eth.account.sign_transaction(txn, private_key=account.key)
|
|
tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)
|
|
|
|
logger.info(f"Collect fees sent: {tx_hash.hex()}")
|
|
logger.info(f"Arbiscan: https://arbiscan.io/tx/{tx_hash.hex()}")
|
|
|
|
# Wait with longer timeout
|
|
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=600)
|
|
|
|
if receipt.status == 1:
|
|
logger.info(f"[SUCCESS] Fees collected from position {token_id}")
|
|
return True, tx_hash.hex()
|
|
else:
|
|
logger.error(f"[ERROR] Fee collection failed for position {token_id}. Status: {receipt.status}")
|
|
return False, tx_hash.hex()
|
|
|
|
except Exception as e:
|
|
if attempt < max_retries - 1:
|
|
logger.warning(f"Attempt {attempt + 1} failed for position {token_id}: {e}. Retrying...")
|
|
time.sleep(5) # Wait before retry
|
|
else:
|
|
logger.error(f"[ERROR] All {max_retries} attempts failed for position {token_id}: {e}")
|
|
return False, None
|
|
|
|
def decrease_liquidity_with_retry(w3, npm_contract, account, token_id, liquidity, max_retries=3):
|
|
"""Decrease liquidity with enhanced retry and gas settings"""
|
|
for attempt in range(max_retries):
|
|
try:
|
|
logger.info(f"Attempt {attempt + 1}: Decreasing liquidity {liquidity} from position {token_id}")
|
|
|
|
txn = npm_contract.functions.decreaseLiquidity(
|
|
(token_id, liquidity, 0, 0, int(time.time()) + 300) # 5 min deadline
|
|
).build_transaction({
|
|
'from': account.address,
|
|
'nonce': w3.eth.get_transaction_count(account.address),
|
|
'gas': 500000, # Much higher gas limit for safety
|
|
'maxFeePerGas': w3.eth.gas_price * 4, # 4x gas price
|
|
'maxPriorityFeePerGas': w3.eth.max_priority_fee * 3,
|
|
'chainId': w3.eth.chain_id
|
|
})
|
|
|
|
signed_txn = w3.eth.account.sign_transaction(txn, private_key=account.key)
|
|
tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)
|
|
|
|
logger.info(f"Decrease liquidity sent: {tx_hash.hex()}")
|
|
logger.info(f"Arbiscan: https://arbiscan.io/tx/{tx_hash.hex()}")
|
|
|
|
# Extended timeout for large transactions
|
|
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=900) # 15 minutes
|
|
|
|
if receipt.status == 1:
|
|
logger.info(f"[SUCCESS] Liquidity decreased from position {token_id}")
|
|
return True, tx_hash.hex()
|
|
else:
|
|
logger.error(f"[ERROR] Liquidity decrease failed for position {token_id}. Status: {receipt.status}")
|
|
return False, tx_hash.hex()
|
|
|
|
except Exception as e:
|
|
if attempt < max_retries - 1:
|
|
logger.warning(f"Attempt {attempt + 1} failed for position {token_id}: {e}. Retrying...")
|
|
time.sleep(10) # Longer wait before retry
|
|
else:
|
|
logger.error(f"[ERROR] All {max_retries} attempts failed for position {token_id}: {e}")
|
|
return False, None
|
|
|
|
def analyze_positions(w3, npm_contract, positions):
|
|
"""Analyze all positions and determine required actions"""
|
|
analysis_results = []
|
|
|
|
for position in positions:
|
|
token_id = position.get('token_id')
|
|
status = position.get('status', 'UNKNOWN')
|
|
|
|
try:
|
|
# Get on-chain position details
|
|
onchain_details = get_position_details(w3, npm_contract, token_id)
|
|
|
|
if not onchain_details:
|
|
continue
|
|
|
|
onchain_liquidity = onchain_details['liquidity']
|
|
tokens_owed0 = onchain_details['tokensOwed0']
|
|
tokens_owed1 = onchain_details['tokensOwed1']
|
|
|
|
# Simulate fee collection to get exact amounts
|
|
sim_amount0, sim_amount1 = simulate_fees(w3, npm_contract, token_id)
|
|
|
|
analysis = {
|
|
'token_id': token_id,
|
|
'local_status': status,
|
|
'onchain_liquidity': onchain_liquidity,
|
|
'tokens_owed0': tokens_owed0,
|
|
'tokens_owed1': tokens_owed1,
|
|
'simulated_fees0': sim_amount0,
|
|
'simulated_fees1': sim_amount1,
|
|
'token0_symbol': onchain_details['token0_symbol'],
|
|
'token1_symbol': onchain_details['token1_symbol'],
|
|
'token0_decimals': onchain_details['token0_decimals'],
|
|
'token1_decimals': onchain_details['token1_decimals'],
|
|
'needs_fee_collection': (sim_amount0 > 0 or sim_amount1 > 0),
|
|
'needs_liquidity_decrease': (onchain_liquidity > 0 and status in ['CLOSING', 'OPEN']),
|
|
'status_mismatch': (status == 'CLOSING' and onchain_liquidity == 0),
|
|
'actions_required': []
|
|
}
|
|
|
|
# Determine required actions
|
|
if analysis['needs_fee_collection']:
|
|
analysis['actions_required'].append('COLLECT_FEES')
|
|
|
|
if analysis['needs_liquidity_decrease']:
|
|
analysis['actions_required'].append('DECREASE_LIQUIDITY')
|
|
|
|
if analysis['status_mismatch']:
|
|
analysis['actions_required'].append('FIX_STATUS')
|
|
|
|
analysis_results.append(analysis)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error analyzing position {token_id}: {e}")
|
|
|
|
return analysis_results
|
|
|
|
def execute_actions(w3, npm_contract, account, analysis_results):
|
|
"""Execute required actions based on analysis"""
|
|
results = {
|
|
'fee_collection': {'success': 0, 'failed': 0},
|
|
'liquidity_decrease': {'success': 0, 'failed': 0},
|
|
'status_fixes': {'success': 0, 'failed': 0}
|
|
}
|
|
|
|
if not analysis_results:
|
|
logger.info("No analysis results to process")
|
|
return results
|
|
|
|
for analysis in analysis_results:
|
|
token_id = analysis.get('token_id', 'Unknown')
|
|
actions = analysis.get('actions_required', [])
|
|
|
|
logger.info(f"\n--- Processing Position {token_id} ---")
|
|
logger.info(f"Local Status: {analysis.get('local_status', 'Unknown')}")
|
|
logger.info(f"On-chain Liquidity: {analysis.get('onchain_liquidity', 0)}")
|
|
logger.info(f"Pending Fees: {from_wei(analysis.get('simulated_fees0', 0), analysis.get('token0_decimals', 18)):.6f} {analysis.get('token0_symbol', 'Unknown')} + {from_wei(analysis.get('simulated_fees1', 0), analysis.get('token1_decimals', 6)):.6f} {analysis.get('token1_symbol', 'Unknown')}")
|
|
logger.info(f"Required Actions: {', '.join(actions)}")
|
|
|
|
# Execute fee collection
|
|
if 'COLLECT_FEES' in actions:
|
|
success, tx_hash = collect_fees(w3, npm_contract, account, token_id)
|
|
if success:
|
|
results['fee_collection']['success'] += 1
|
|
else:
|
|
results['fee_collection']['failed'] += 1
|
|
time.sleep(3) # Brief pause between operations
|
|
|
|
# Execute liquidity decrease
|
|
if 'DECREASE_LIQUIDITY' in actions:
|
|
liquidity = analysis.get('onchain_liquidity', 0)
|
|
success, tx_hash = decrease_liquidity_with_retry(w3, npm_contract, account, token_id, liquidity)
|
|
if success:
|
|
results['liquidity_decrease']['success'] += 1
|
|
# Update status to CLOSING if successful decrease
|
|
update_position_status(token_id, 'CLOSING')
|
|
else:
|
|
results['liquidity_decrease']['failed'] += 1
|
|
time.sleep(3)
|
|
|
|
# Fix status mismatch
|
|
if 'FIX_STATUS' in actions:
|
|
success = update_position_status(token_id, 'CLOSED')
|
|
if success:
|
|
results['status_fixes']['success'] += 1
|
|
logger.info(f"[SUCCESS] Fixed status for position {token_id}")
|
|
else:
|
|
results['status_fixes']['failed'] += 1
|
|
|
|
return results
|
|
|
|
def main():
|
|
logger.info("=== Fee Collection & Position Recovery Script ===")
|
|
logger.info("This script will collect all fees and handle stuck positions")
|
|
|
|
# Load environment
|
|
load_dotenv(override=True)
|
|
|
|
rpc_url = os.environ.get("MAINNET_RPC_URL")
|
|
private_key = os.environ.get("MAIN_WALLET_PRIVATE_KEY") or os.environ.get("PRIVATE_KEY")
|
|
|
|
if not rpc_url or not private_key:
|
|
logger.error("[ERROR] Missing RPC URL or Private Key")
|
|
return
|
|
|
|
# Connect to Arbitrum
|
|
try:
|
|
w3 = Web3(Web3.HTTPProvider(rpc_url))
|
|
if not w3.is_connected():
|
|
logger.error("[ERROR] Failed to connect to Arbitrum RPC")
|
|
return
|
|
logger.info(f"[SUCCESS] Connected to Chain ID: {w3.eth.chain_id}")
|
|
except Exception as e:
|
|
logger.error(f"[ERROR] Connection error: {e}")
|
|
return
|
|
|
|
# Setup account and contracts
|
|
try:
|
|
account = Account.from_key(private_key)
|
|
w3.eth.default_account = account.address
|
|
logger.info(f"Wallet: {account.address}")
|
|
|
|
npm_contract = w3.eth.contract(address=NONFUNGIBLE_POSITION_MANAGER_ADDRESS, abi=NONFUNGIBLE_POSITION_MANAGER_ABI)
|
|
|
|
except Exception as e:
|
|
logger.error(f"[ERROR] Account/Contract setup error: {e}")
|
|
return
|
|
|
|
# Load and analyze positions
|
|
positions = load_status_file()
|
|
if not positions:
|
|
logger.info("No positions found in status file")
|
|
return
|
|
|
|
logger.info(f"Found {len(positions)} positions in status file")
|
|
|
|
# Analyze all positions
|
|
analysis_results = analyze_positions(w3, npm_contract, positions)
|
|
|
|
logger.info(f"\n=== Analysis Results ===")
|
|
for analysis in analysis_results:
|
|
logger.info(f"Position {analysis['token_id']}: {', '.join(analysis['actions_required']) if analysis['actions_required'] else 'NO ACTION NEEDED'}")
|
|
|
|
# Confirm execution
|
|
total_actions = sum(len(analysis['actions_required']) for analysis in analysis_results)
|
|
if total_actions == 0:
|
|
logger.info("\n[INFO] No actions required. All positions are clean.")
|
|
return
|
|
|
|
print(f"\nTotal actions required: {total_actions}")
|
|
confirm = input("Proceed with fee collection and position recovery? (y/N): ").strip().lower()
|
|
if confirm != 'y':
|
|
logger.info("Operation cancelled by user")
|
|
return
|
|
|
|
# Execute all actions
|
|
logger.info("\n=== Executing Recovery Actions ===")
|
|
results = execute_actions(w3, npm_contract, account, analysis_results)
|
|
|
|
# Report final results
|
|
logger.info(f"\n=== Final Results ===")
|
|
logger.info(f"Fee Collection: {results['fee_collection']['success']} success, {results['fee_collection']['failed']} failed")
|
|
logger.info(f"Liquidity Decrease: {results['liquidity_decrease']['success']} success, {results['liquidity_decrease']['failed']} failed")
|
|
logger.info(f"Status Fixes: {results['status_fixes']['success']} success, {results['status_fixes']['failed']} failed")
|
|
|
|
total_success = results['fee_collection']['success'] + results['liquidity_decrease']['success'] + results['status_fixes']['success']
|
|
total_failed = results['fee_collection']['failed'] + results['liquidity_decrease']['failed'] + results['status_fixes']['failed']
|
|
|
|
if total_success > 0:
|
|
logger.info(f"[SUCCESS] {total_success} operations completed successfully!")
|
|
|
|
if total_failed > 0:
|
|
logger.warning(f"[WARNING] {total_failed} operations failed. Check collect_fees.log for details.")
|
|
|
|
logger.info("=== Recovery Script Complete ===")
|
|
|
|
if __name__ == "__main__":
|
|
main() |