623 lines
22 KiB
Python
623 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Async Auto-Redeemer
|
|
|
|
Runs every 3 minutes in background, checks for redeemable positions
|
|
and automatically redeems them.
|
|
|
|
Fully async - does not block main event loop.
|
|
"""
|
|
|
|
import os
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from datetime import datetime
|
|
from typing import Optional, Tuple, List, Dict, Any
|
|
|
|
import aiohttp
|
|
from web3 import Web3
|
|
from eth_account import Account
|
|
|
|
from src.proxy_util import apply_proxy_env, aiohttp_proxy
|
|
|
|
apply_proxy_env()
|
|
_PROXY_URL = aiohttp_proxy() or ""
|
|
|
|
logger = logging.getLogger("btc_live.redeemer")
|
|
|
|
# Dedicated thread pool for web3 operations to avoid blocking main thread pool
|
|
_WEB3_EXECUTOR = ThreadPoolExecutor(max_workers=2, thread_name_prefix="web3_redeemer")
|
|
|
|
# Contract addresses
|
|
USDC_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
|
CTF_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
|
NEG_RISK_ADAPTER = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"
|
|
|
|
DATA_API = "https://data-api.polymarket.com"
|
|
|
|
CTF_ABI = json.loads('''[
|
|
{
|
|
"inputs": [
|
|
{"internalType": "address", "name": "account", "type": "address"},
|
|
{"internalType": "uint256", "name": "id", "type": "uint256"}
|
|
],
|
|
"name": "balanceOf",
|
|
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
|
|
"stateMutability": "view",
|
|
"type": "function"
|
|
},
|
|
{
|
|
"inputs": [
|
|
{"internalType": "bytes32", "name": "conditionId", "type": "bytes32"}
|
|
],
|
|
"name": "payoutDenominator",
|
|
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
|
|
"stateMutability": "view",
|
|
"type": "function"
|
|
},
|
|
{
|
|
"inputs": [
|
|
{"internalType": "address", "name": "collateralToken", "type": "address"},
|
|
{"internalType": "bytes32", "name": "parentCollectionId", "type": "bytes32"},
|
|
{"internalType": "bytes32", "name": "conditionId", "type": "bytes32"},
|
|
{"internalType": "uint256[]", "name": "indexSets", "type": "uint256[]"}
|
|
],
|
|
"name": "redeemPositions",
|
|
"outputs": [],
|
|
"stateMutability": "nonpayable",
|
|
"type": "function"
|
|
}
|
|
]''')
|
|
|
|
NEG_RISK_ABI = json.loads('''[
|
|
{
|
|
"inputs": [
|
|
{"internalType": "bytes32", "name": "conditionId", "type": "bytes32"},
|
|
{"internalType": "uint256[]", "name": "amounts", "type": "uint256[]"}
|
|
],
|
|
"name": "redeemPositions",
|
|
"outputs": [],
|
|
"stateMutability": "nonpayable",
|
|
"type": "function"
|
|
}
|
|
]''')
|
|
|
|
GNOSIS_SAFE_ABI = json.loads('''[
|
|
{
|
|
"inputs": [
|
|
{"name": "to", "type": "address"},
|
|
{"name": "value", "type": "uint256"},
|
|
{"name": "data", "type": "bytes"},
|
|
{"name": "operation", "type": "uint8"},
|
|
{"name": "safeTxGas", "type": "uint256"},
|
|
{"name": "baseGas", "type": "uint256"},
|
|
{"name": "gasPrice", "type": "uint256"},
|
|
{"name": "gasToken", "type": "address"},
|
|
{"name": "refundReceiver", "type": "address"},
|
|
{"name": "signatures", "type": "bytes"}
|
|
],
|
|
"name": "execTransaction",
|
|
"outputs": [{"name": "success", "type": "bool"}],
|
|
"stateMutability": "payable",
|
|
"type": "function"
|
|
},
|
|
{
|
|
"inputs": [],
|
|
"name": "nonce",
|
|
"outputs": [{"name": "", "type": "uint256"}],
|
|
"stateMutability": "view",
|
|
"type": "function"
|
|
},
|
|
{
|
|
"inputs": [
|
|
{"name": "to", "type": "address"},
|
|
{"name": "value", "type": "uint256"},
|
|
{"name": "data", "type": "bytes"},
|
|
{"name": "operation", "type": "uint8"},
|
|
{"name": "safeTxGas", "type": "uint256"},
|
|
{"name": "baseGas", "type": "uint256"},
|
|
{"name": "gasPrice", "type": "uint256"},
|
|
{"name": "gasToken", "type": "address"},
|
|
{"name": "refundReceiver", "type": "address"},
|
|
{"name": "_nonce", "type": "uint256"}
|
|
],
|
|
"name": "getTransactionHash",
|
|
"outputs": [{"name": "", "type": "bytes32"}],
|
|
"stateMutability": "view",
|
|
"type": "function"
|
|
}
|
|
]''')
|
|
|
|
|
|
class AsyncAutoRedeemer:
|
|
"""
|
|
Async auto-redeemer that runs in background every N minutes.
|
|
|
|
Features:
|
|
- Fully async (aiohttp for API, asyncio.to_thread for web3)
|
|
- File lock to prevent concurrent redemptions
|
|
- Supports both EOA and Proxy (Gnosis Safe) wallets
|
|
- Telegram notifications on successful redeem
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
private_key: str,
|
|
rpc_url: str,
|
|
funder_address: Optional[str] = None,
|
|
signature_type: int = 0,
|
|
interval_seconds: int = 180, # 3 minutes
|
|
telegram_notifier: Optional[Any] = None
|
|
):
|
|
self.private_key = private_key
|
|
self.rpc_url = rpc_url
|
|
self.funder_address = funder_address
|
|
self.signature_type = signature_type
|
|
self.interval = interval_seconds
|
|
self.telegram = telegram_notifier
|
|
|
|
# Web3 setup
|
|
self.w3 = Web3(Web3.HTTPProvider(rpc_url))
|
|
|
|
# Add POA middleware for Polygon
|
|
from web3.middleware import ExtraDataToPOAMiddleware
|
|
self.w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
|
|
|
|
# Account
|
|
self.account = Account.from_key(private_key)
|
|
self.signer_address = self.account.address
|
|
|
|
# Wallet to check for positions
|
|
if signature_type in [1, 2] and funder_address:
|
|
self.wallet_address = funder_address
|
|
else:
|
|
self.wallet_address = self.signer_address
|
|
|
|
# Contracts
|
|
self.ctf = self.w3.eth.contract(
|
|
address=Web3.to_checksum_address(CTF_ADDRESS),
|
|
abi=CTF_ABI
|
|
)
|
|
|
|
# Stats
|
|
self.total_redeemed = 0
|
|
self.total_value = 0.0
|
|
self._running = False
|
|
self._lock_fd = None
|
|
|
|
# Semaphore to limit concurrent web3 operations (prevents thread pool saturation)
|
|
self._redeem_semaphore = asyncio.Semaphore(1) # Only 1 redemption at a time
|
|
|
|
async def _fetch_positions(self) -> Tuple[List[Dict], List[Dict], List[Dict]]:
|
|
"""Fetch all positions from Polymarket Data API (async)."""
|
|
active = []
|
|
pending = []
|
|
redeemable = []
|
|
|
|
try:
|
|
timeout = aiohttp.ClientTimeout(total=30, connect=10)
|
|
async with aiohttp.ClientSession(
|
|
timeout=timeout, trust_env=True
|
|
) as session:
|
|
url = f"{DATA_API}/positions"
|
|
params = {
|
|
"user": self.wallet_address,
|
|
"limit": 500,
|
|
"sizeThreshold": 0.01
|
|
}
|
|
|
|
async with session.get(
|
|
url, params=params, proxy=_PROXY_URL or None
|
|
) as resp:
|
|
if resp.status != 200:
|
|
logger.error(f"Data API returned {resp.status}")
|
|
return active, pending, redeemable
|
|
|
|
positions = await resp.json()
|
|
|
|
if not positions:
|
|
return active, pending, redeemable
|
|
|
|
# Group by conditionId
|
|
positions_by_condition = {}
|
|
for pos in positions:
|
|
condition_id = pos.get("conditionId")
|
|
if not condition_id:
|
|
continue
|
|
|
|
if condition_id not in positions_by_condition:
|
|
positions_by_condition[condition_id] = {
|
|
"slug": pos.get("slug", "unknown"),
|
|
"title": pos.get("title", "Unknown Market"),
|
|
"condition_id": condition_id,
|
|
"neg_risk": pos.get("negativeRisk", False),
|
|
"end_date": pos.get("endDate"),
|
|
"redeemable": pos.get("redeemable", False),
|
|
"outcomes": {}
|
|
}
|
|
|
|
outcome = pos.get("outcome", "")
|
|
positions_by_condition[condition_id]["outcomes"][outcome] = {
|
|
"asset": pos.get("asset"),
|
|
"size": int(float(pos.get("size", 0)) * 1e6),
|
|
"cur_price": pos.get("curPrice", 0),
|
|
}
|
|
|
|
# Categorize
|
|
import time
|
|
now = int(time.time())
|
|
|
|
for condition_id, pos_data in positions_by_condition.items():
|
|
outcomes = pos_data["outcomes"]
|
|
|
|
up_data = outcomes.get("Up") or outcomes.get("YES") or outcomes.get("Higher")
|
|
down_data = outcomes.get("Down") or outcomes.get("NO") or outcomes.get("Lower")
|
|
|
|
if not up_data and not down_data:
|
|
outcome_list = list(outcomes.values())
|
|
up_data = outcome_list[0] if len(outcome_list) > 0 else None
|
|
down_data = outcome_list[1] if len(outcome_list) > 1 else None
|
|
|
|
up_balance = up_data.get("size", 0) if up_data else 0
|
|
down_balance = down_data.get("size", 0) if down_data else 0
|
|
|
|
if up_balance == 0 and down_balance == 0:
|
|
continue
|
|
|
|
position_data = {
|
|
"slug": pos_data["slug"],
|
|
"title": pos_data["title"],
|
|
"condition_id": condition_id,
|
|
"up_token_id": up_data.get("asset") if up_data else None,
|
|
"down_token_id": down_data.get("asset") if down_data else None,
|
|
"up_balance": up_balance,
|
|
"down_balance": down_balance,
|
|
"neg_risk": pos_data["neg_risk"],
|
|
}
|
|
|
|
end_date = pos_data.get("end_date")
|
|
is_closed = False
|
|
if end_date:
|
|
try:
|
|
end_timestamp = datetime.fromisoformat(
|
|
end_date.replace('Z', '+00:00')
|
|
).timestamp()
|
|
is_closed = now >= end_timestamp
|
|
except:
|
|
pass
|
|
|
|
if pos_data["redeemable"]:
|
|
redeemable.append(position_data)
|
|
elif is_closed:
|
|
pending.append(position_data)
|
|
else:
|
|
active.append(position_data)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching positions: {e}")
|
|
|
|
return active, pending, redeemable
|
|
|
|
def _check_oracle_resolution(self, condition_id: str) -> bool:
|
|
"""Check if oracle has resolved (sync, runs in thread)."""
|
|
try:
|
|
condition_bytes = Web3.to_bytes(hexstr=condition_id)
|
|
payout_denom = self.ctf.functions.payoutDenominator(condition_bytes).call()
|
|
return payout_denom > 0
|
|
except Exception as e:
|
|
logger.error(f"Oracle check error: {e}")
|
|
return False
|
|
|
|
def _redeem_position_sync(self, position: Dict) -> bool:
|
|
"""Redeem a single position (sync, runs in thread)."""
|
|
import fcntl
|
|
import time
|
|
|
|
condition_id = position["condition_id"]
|
|
up_balance = position["up_balance"]
|
|
down_balance = position["down_balance"]
|
|
is_neg_risk = position.get("neg_risk", False)
|
|
|
|
logger.info(f"Redeeming: {position['slug']}")
|
|
|
|
# Check oracle first
|
|
if not self._check_oracle_resolution(condition_id):
|
|
logger.warning(f"Skipping {position['slug']} - oracle not resolved")
|
|
return False
|
|
|
|
# File lock
|
|
lock_file = "/tmp/btc_live_redeem.lock"
|
|
try:
|
|
self._lock_fd = open(lock_file, 'w')
|
|
fcntl.flock(self._lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except (IOError, OSError):
|
|
logger.warning("Another redeem in progress, skipping")
|
|
return False
|
|
|
|
try:
|
|
use_proxy = self.signature_type in [1, 2] and self.funder_address
|
|
|
|
time.sleep(0.5)
|
|
|
|
if use_proxy:
|
|
# Gnosis Safe proxy wallet
|
|
return self._redeem_via_safe(condition_id, up_balance, down_balance, is_neg_risk)
|
|
else:
|
|
# Direct EOA
|
|
return self._redeem_direct(condition_id, up_balance, down_balance, is_neg_risk)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Redeem error: {e}")
|
|
return False
|
|
finally:
|
|
if self._lock_fd:
|
|
try:
|
|
fcntl.flock(self._lock_fd, fcntl.LOCK_UN)
|
|
self._lock_fd.close()
|
|
except:
|
|
pass
|
|
self._lock_fd = None
|
|
|
|
def _redeem_direct(
|
|
self,
|
|
condition_id: str,
|
|
up_balance: int,
|
|
down_balance: int,
|
|
is_neg_risk: bool
|
|
) -> bool:
|
|
"""Direct EOA redeem."""
|
|
import time
|
|
|
|
nonce = self.w3.eth.get_transaction_count(self.signer_address)
|
|
time.sleep(0.3)
|
|
gas_price = self.w3.eth.gas_price
|
|
|
|
if is_neg_risk:
|
|
adapter = self.w3.eth.contract(
|
|
address=Web3.to_checksum_address(NEG_RISK_ADAPTER),
|
|
abi=NEG_RISK_ABI
|
|
)
|
|
tx = adapter.functions.redeemPositions(
|
|
Web3.to_bytes(hexstr=condition_id),
|
|
[up_balance, down_balance]
|
|
).build_transaction({
|
|
"chainId": 137,
|
|
"from": self.signer_address,
|
|
"nonce": nonce,
|
|
"gas": 500000,
|
|
"gasPrice": int(gas_price * 1.2),
|
|
})
|
|
else:
|
|
tx = self.ctf.functions.redeemPositions(
|
|
Web3.to_checksum_address(USDC_ADDRESS),
|
|
bytes(32),
|
|
Web3.to_bytes(hexstr=condition_id),
|
|
[1, 2]
|
|
).build_transaction({
|
|
"chainId": 137,
|
|
"from": self.signer_address,
|
|
"nonce": nonce,
|
|
"gas": 500000,
|
|
"gasPrice": int(gas_price * 1.2),
|
|
})
|
|
|
|
signed_tx = self.w3.eth.account.sign_transaction(tx, self.private_key)
|
|
tx_hash = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
|
|
|
logger.info(f"TX sent: {tx_hash.hex()}")
|
|
|
|
receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
|
|
|
|
if receipt.get("status") == 1:
|
|
logger.info(f"Redeem successful! Gas: {receipt.get('gasUsed')}")
|
|
return True
|
|
else:
|
|
logger.error(f"TX reverted: {tx_hash.hex()}")
|
|
return False
|
|
|
|
def _redeem_via_safe(
|
|
self,
|
|
condition_id: str,
|
|
up_balance: int,
|
|
down_balance: int,
|
|
is_neg_risk: bool
|
|
) -> bool:
|
|
"""Redeem via Gnosis Safe proxy wallet."""
|
|
import time
|
|
|
|
safe_address = Web3.to_checksum_address(self.funder_address)
|
|
safe = self.w3.eth.contract(address=safe_address, abi=GNOSIS_SAFE_ABI)
|
|
|
|
# Build inner redeem call
|
|
if is_neg_risk:
|
|
adapter = self.w3.eth.contract(
|
|
address=Web3.to_checksum_address(NEG_RISK_ADAPTER),
|
|
abi=NEG_RISK_ABI
|
|
)
|
|
temp_tx = adapter.functions.redeemPositions(
|
|
Web3.to_bytes(hexstr=condition_id),
|
|
[up_balance, down_balance]
|
|
).build_transaction({"from": safe_address})
|
|
redeem_data = temp_tx['data']
|
|
target_contract = NEG_RISK_ADAPTER
|
|
else:
|
|
temp_tx = self.ctf.functions.redeemPositions(
|
|
Web3.to_checksum_address(USDC_ADDRESS),
|
|
bytes(32),
|
|
Web3.to_bytes(hexstr=condition_id),
|
|
[1, 2]
|
|
).build_transaction({"from": safe_address})
|
|
redeem_data = temp_tx['data']
|
|
target_contract = CTF_ADDRESS
|
|
|
|
time.sleep(0.5)
|
|
eoa_nonce = self.w3.eth.get_transaction_count(self.signer_address)
|
|
time.sleep(0.3)
|
|
gas_price = self.w3.eth.gas_price
|
|
safe_nonce = safe.functions.nonce().call()
|
|
|
|
# Safe TX params
|
|
to = Web3.to_checksum_address(target_contract)
|
|
value = 0
|
|
data = redeem_data
|
|
operation = 0
|
|
safeTxGas = 0
|
|
baseGas = 0
|
|
gasPrice_safe = 0
|
|
gasToken = "0x0000000000000000000000000000000000000000"
|
|
refundReceiver = "0x0000000000000000000000000000000000000000"
|
|
|
|
# Get TX hash to sign
|
|
tx_hash_to_sign = safe.functions.getTransactionHash(
|
|
to, value, data, operation,
|
|
safeTxGas, baseGas, gasPrice_safe,
|
|
gasToken, refundReceiver, safe_nonce
|
|
).call()
|
|
|
|
# Sign
|
|
signed_msg = self.account.unsafe_sign_hash(tx_hash_to_sign)
|
|
r = signed_msg.r.to_bytes(32, byteorder='big')
|
|
s = signed_msg.s.to_bytes(32, byteorder='big')
|
|
v = signed_msg.v
|
|
signature = r + s + bytes([v])
|
|
|
|
# Build execTransaction
|
|
tx = safe.functions.execTransaction(
|
|
to, value, data, operation,
|
|
safeTxGas, baseGas, gasPrice_safe,
|
|
gasToken, refundReceiver, signature
|
|
).build_transaction({
|
|
"chainId": 137,
|
|
"from": self.signer_address,
|
|
"nonce": eoa_nonce,
|
|
"gas": 1000000,
|
|
"gasPrice": int(gas_price * 1.2),
|
|
})
|
|
|
|
time.sleep(0.5)
|
|
signed_tx = self.w3.eth.account.sign_transaction(tx, self.private_key)
|
|
tx_hash = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
|
|
|
logger.info(f"Safe TX sent: {tx_hash.hex()}")
|
|
|
|
receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
|
|
|
|
if receipt.get("status") == 1:
|
|
logger.info(f"Safe redeem successful! Gas: {receipt.get('gasUsed')}")
|
|
return True
|
|
else:
|
|
logger.error(f"Safe TX reverted: {tx_hash.hex()}")
|
|
return False
|
|
|
|
async def redeem_all(self) -> Tuple[int, float]:
|
|
"""
|
|
Check and redeem all redeemable positions.
|
|
|
|
Returns:
|
|
Tuple of (redeemed_count, total_value_usd)
|
|
"""
|
|
logger.info("Starting auto-redeem check...")
|
|
|
|
active, pending, redeemable = await self._fetch_positions()
|
|
|
|
logger.info(f"Found: {len(active)} active, {len(pending)} pending, {len(redeemable)} redeemable")
|
|
|
|
if not redeemable:
|
|
return 0, 0.0
|
|
|
|
redeemed_count = 0
|
|
total_value = 0.0
|
|
|
|
for position in redeemable:
|
|
# Use semaphore to limit concurrent redemptions
|
|
# Use dedicated thread pool to avoid blocking main pool
|
|
async with self._redeem_semaphore:
|
|
loop = asyncio.get_event_loop()
|
|
success = await loop.run_in_executor(
|
|
_WEB3_EXECUTOR,
|
|
self._redeem_position_sync,
|
|
position
|
|
)
|
|
|
|
if success:
|
|
redeemed_count += 1
|
|
value = (position["up_balance"] + position["down_balance"]) / 1e6
|
|
total_value += value
|
|
|
|
self.total_redeemed += 1
|
|
self.total_value += value
|
|
|
|
# Telegram notification
|
|
if self.telegram:
|
|
try:
|
|
await self.telegram.send_message(
|
|
f"💰 Redeemed: {position['slug']}\n"
|
|
f"Value: ${value:.2f} USDC"
|
|
)
|
|
except:
|
|
pass
|
|
|
|
# Pause between redemptions
|
|
await asyncio.sleep(2)
|
|
|
|
logger.info(f"Redeemed {redeemed_count}/{len(redeemable)}, value: ${total_value:.2f}")
|
|
|
|
return redeemed_count, total_value
|
|
|
|
async def run_loop(self):
|
|
"""
|
|
Main loop - runs every N seconds.
|
|
Fully async, never blocks.
|
|
"""
|
|
self._running = True
|
|
logger.info(f"Auto-redeemer started (interval: {self.interval}s)")
|
|
|
|
while self._running:
|
|
try:
|
|
await asyncio.sleep(self.interval)
|
|
|
|
redeemed, value = await self.redeem_all()
|
|
|
|
if redeemed > 0:
|
|
logger.info(f"Auto-redeemed {redeemed} positions, ${value:.2f}")
|
|
|
|
except asyncio.CancelledError:
|
|
logger.info("Auto-redeemer cancelled")
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Auto-redeem loop error: {e}")
|
|
# Continue running despite errors
|
|
await asyncio.sleep(10)
|
|
|
|
logger.info("Auto-redeemer stopped")
|
|
|
|
def stop(self):
|
|
"""Stop the redeemer loop."""
|
|
self._running = False
|
|
|
|
@staticmethod
|
|
def shutdown_executor():
|
|
"""Shutdown the dedicated thread pool on application exit."""
|
|
global _WEB3_EXECUTOR
|
|
if _WEB3_EXECUTOR:
|
|
_WEB3_EXECUTOR.shutdown(wait=False)
|
|
logger.info("Web3 executor shut down")
|
|
|
|
|
|
async def create_auto_redeemer(config: Dict) -> AsyncAutoRedeemer:
|
|
"""
|
|
Factory function to create redeemer from config.
|
|
|
|
Args:
|
|
config: Dict with keys: private_key, rpc_url, funder_address, signature_type
|
|
"""
|
|
return AsyncAutoRedeemer(
|
|
private_key=config.get("private_key"),
|
|
rpc_url=config.get("rpc_url", "https://polygon-rpc.com"),
|
|
funder_address=config.get("funder_address"),
|
|
signature_type=config.get("signature_type", 0),
|
|
interval_seconds=config.get("redeem_interval", 180),
|
|
telegram_notifier=config.get("telegram_notifier")
|
|
) |