Files
polymarket-5min-15min-1hour…/btc-binary-VWAP-Momentum-bot/src/auto_redeemer.py
T
2026-07-26 22:56:35 +08:00

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")
)