Garden-Angel-Ai-35Bot / modules /contract_manager.py
Claude
Add Uniswap V3 quoting + mixed-leg execution path for real Arbitrum profit
cee9053 unverified
Raw
History Blame Contribute Delete
18.8 kB
"""
modules/contract_manager.py — Garden Angel On-Chain Execution Layer v2.4
──────────────────────────────────────────────────────────────────────────────
v2.4 changelog (this revision):
- NEW — "mdex" added to ROUTER_MAP. Confirmed via BscScan's own "Mdex:
Router" label (user-supplied screenshot, 2026-07-10), matching MDEX's
official URL (bsc.mdex.com) — 0x7DAe51BD3E3376B8c7c4900E9107f12Be3AF1bA8.
Kept in sync with scanner.py v18.10's _BSC_ROUTER_ADDRESSES.
v2.3 changelog:
- FIXED withdraw ABI/selector mismatch. The deployed FlashArbitrageV2 exposes
withdraw(address token) — ONE argument — which sends the full token balance
to the contract owner. This module previously declared and called a
two-argument withdraw(address token, uint256 amount), so /withdraw encoded a
selector that does not exist on-chain and always reverted, making stuck-token
recovery impossible. The ABI entry and ContractManager.withdraw() now use the
1-arg form. withdraw() still ACCEPTS an optional `amount` for backward
compatibility with older callers, but ignores it (the contract always sweeps
the full balance). VERIFY against your actual deployed contract on BscScan
before relying on this — if your deployment really is 2-arg, revert this.
v2.2 changelog:
- FIXED CHAIN MISMATCH: replaced Ethereum-mainnet AssetRegistry/ROUTER_MAP
entries with verified BSC mainnet addresses (Binance-Peg tokens are 18
decimals across the board; do not copy ETH-side 8/6-decimal values).
Required env vars:
RPC_URL
FLASH_ARBITRAGE_CONTRACT_ADDRESS
PAYOUT_PRIVATE_KEY (optional — read-only mode without it)
"""
from __future__ import annotations
import json
import logging
import os
from decimal import Decimal
from typing import Optional
from web3 import Web3
from web3.exceptions import ContractLogicError, TransactionNotFound # noqa: F401 (re-exported for callers)
from modules.web3_client import Web3Client, get_web3_client
logger = logging.getLogger("garden_angel.contract_manager")
# ---------------------------------------------------------------------------
# FlashArbitrageV2 contract interface
# ---------------------------------------------------------------------------
# Matches the deployed contract exactly: startArbitrage takes a single
# `intermediateToken` address per leg, and withdraw takes a single `token`
# address (full-balance sweep to owner). There is no quoteRoundTrip view
# function on-chain.
FLASH_ARBITRAGE_V2_ABI = json.loads("""
[
{"inputs": [], "name": "owner", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"},
{"inputs": [], "name": "aavePool", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"},
{"inputs": [
{"internalType": "address", "name": "asset", "type": "address"},
{"internalType": "uint256", "name": "amount", "type": "uint256"},
{"internalType": "address", "name": "routerBuy", "type": "address"},
{"internalType": "address", "name": "routerSell", "type": "address"},
{"internalType": "address", "name": "intermediateToken", "type": "address"},
{"internalType": "uint256", "name": "minIntermediateOut", "type": "uint256"},
{"internalType": "uint256", "name": "minFinalOut", "type": "uint256"},
{"internalType": "uint256", "name": "minProfit", "type": "uint256"}
], "name": "startArbitrage", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
{"inputs": [
{"internalType": "address", "name": "token", "type": "address"}
], "name": "withdraw", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
{"inputs": [], "name": "withdrawETH", "outputs": [], "stateMutability": "nonpayable", "type": "function"}
]
""")
# Minimal ERC-20 ABI slice — just enough to read decimals() live as a
# fallback/verification path when an asset isn't in the static registry.
ERC20_DECIMALS_ABI = json.loads("""
[
{"inputs": [], "name": "decimals", "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], "stateMutability": "view", "type": "function"}
]
""")
# ---------------------------------------------------------------------------
# AssetRegistry — decimals lookup (checksummed BSC mainnet addresses)
# ---------------------------------------------------------------------------
# Binance-Peg tokens on BSC use 18 decimals across the board, even for assets
# whose Ethereum-native counterpart uses fewer (BTCB=18 vs mainnet WBTC=8).
class AssetRegistry:
"""Static decimals lookup keyed by checksummed asset address, with a live
on-chain fallback (ERC-20 decimals()) for anything not listed here."""
# Migrated 2026-07 to Arbitrum One. Unlike BSC's uniform 18-decimals,
# Arbitrum uses the tokens' native decimals — USDC/USDT are 6, WBTC is 8.
# Anything not listed falls back to an on-chain decimals() query.
_STATIC: dict[str, int] = {
Web3.to_checksum_address("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"): 18, # WETH
Web3.to_checksum_address("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"): 8, # WBTC (8!)
Web3.to_checksum_address("0x912CE59144191C1204E64559FE8253a0e49E6548"): 18, # ARB
Web3.to_checksum_address("0xfc5A1A6EB076a2C7aD06eD22C90d7E710E35ad0a"): 18, # GMX
Web3.to_checksum_address("0xf97f4df75117a78c1A5a0DBb814Af92458539FB4"): 18, # LINK
Web3.to_checksum_address("0x5979D7b546E38E414F7E9822514be443A4800529"): 18, # wstETH
Web3.to_checksum_address("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"): 6, # USDC native (6!)
Web3.to_checksum_address("0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8"): 6, # USDC.e bridged (6!)
Web3.to_checksum_address("0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"): 6, # USDT (6!)
Web3.to_checksum_address("0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"): 18, # DAI
}
def __init__(self, w3: Web3):
self._w3 = w3
self._cache: dict[str, int] = dict(self._STATIC)
def decimals(self, asset_address: str) -> int:
addr = Web3.to_checksum_address(asset_address)
if addr in self._cache:
return self._cache[addr]
logger.warning(
"AssetRegistry: %s not in static registry — querying decimals() on-chain.",
addr,
)
token = self._w3.eth.contract(address=addr, abi=ERC20_DECIMALS_ABI)
d = token.functions.decimals().call()
self._cache[addr] = d
return d
def scale_amount(self, asset_address: str, human_amount: Decimal | float | int) -> int:
"""Convert a human-readable token amount into integer base units using
that asset's actual decimals rather than an assumed 18."""
d = self.decimals(asset_address)
return int(Decimal(str(human_amount)) * (Decimal(10) ** d))
# ---------------------------------------------------------------------------
# ROUTER_MAP — DEX name -> checksummed Router02 address (BSC mainnet)
# ---------------------------------------------------------------------------
# ROUTER_MAP — DEX name -> checksummed Router address (Arbitrum One mainnet).
# Only Uniswap-V2-style routers (swapExactTokensForTokens) belong here; that
# is the ABI shape LocalExecutor builds its swaps against. Kept in sync with
# scanner.py's _BSC_ROUTER_ADDRESSES / _BSC_EXECUTABLE_DEXES.
ROUTER_MAP: dict[str, str] = {
"sushiswap": Web3.to_checksum_address("0x1b02dA8Cb0d097eB8D57A175b88c7D8b47997506"), # SushiSwap V2 Router
"camelot": Web3.to_checksum_address("0xc873fEcbd354f5A56E00E710B90EF4201db2448d"), # Camelot V2 Router
# Uniswap V3 / Camelot V3 use different interfaces (exactInputSingle /
# Algebra) — deliberately NOT mapped here under the V2-shaped ABI. See
# V3_ROUTER_MAP below and FlashArbitrageV3.sol for the V3-shaped path.
}
# ---------------------------------------------------------------------------
# Uniswap V3 (Arbitrum One) — quoting/execution against a SEPARATE deployed
# contract (FlashArbitrageV3.sol), not the V2-only FlashArbitrageV2. See that
# contract's own header for why this can't just be added to ROUTER_MAP above.
# ---------------------------------------------------------------------------
V3_ROUTER_MAP: dict[str, str] = {
"uniswapv3": Web3.to_checksum_address("0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"), # SwapRouter02
}
UNISWAP_V3_QUOTER_V2 = Web3.to_checksum_address("0x61fFE014bA17989E743c5F6cB21bF9697530B21e")
UNISWAP_V3_FEE_TIERS: tuple[int, ...] = (100, 500, 3000, 10000)
def resolve_v3_router(dex_name: str) -> str:
key = dex_name.strip().lower()
if key not in V3_ROUTER_MAP:
raise ValueError(
f"Unknown V3 DEX '{dex_name}' — not in V3_ROUTER_MAP. "
f"Known: {', '.join(sorted(V3_ROUTER_MAP))}."
)
return V3_ROUTER_MAP[key]
FLASH_ARBITRAGE_V3_ABI = json.loads("""
[
{"inputs": [], "name": "owner", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"},
{"inputs": [], "name": "aavePool", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"},
{"inputs": [
{"internalType": "address", "name": "asset", "type": "address"},
{"internalType": "uint256", "name": "amount", "type": "uint256"},
{"internalType": "address", "name": "routerBuy", "type": "address"},
{"internalType": "bool", "name": "buyIsV3", "type": "bool"},
{"internalType": "uint24", "name": "buyFeeTier", "type": "uint24"},
{"internalType": "address", "name": "routerSell", "type": "address"},
{"internalType": "bool", "name": "sellIsV3", "type": "bool"},
{"internalType": "uint24", "name": "sellFeeTier", "type": "uint24"},
{"internalType": "address", "name": "intermediateToken", "type": "address"},
{"internalType": "uint256", "name": "minIntermediateOut", "type": "uint256"},
{"internalType": "uint256", "name": "minFinalOut", "type": "uint256"},
{"internalType": "uint256", "name": "minProfit", "type": "uint256"}
], "name": "startArbitrageMixed", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
{"inputs": [
{"internalType": "address", "name": "token", "type": "address"}
], "name": "withdraw", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
{"inputs": [], "name": "withdrawETH", "outputs": [], "stateMutability": "nonpayable", "type": "function"}
]
""")
def resolve_router(dex_name: str) -> str:
"""Look up a router address by DEX name. Raises rather than guessing if the
name isn't mapped — an unresolved router must never fall back to a default."""
key = dex_name.strip().lower()
if key not in ROUTER_MAP:
raise ValueError(
f"Unknown DEX '{dex_name}' — not in ROUTER_MAP. "
f"Known: {', '.join(sorted(ROUTER_MAP))}. "
"Add it explicitly rather than assuming a default router."
)
return ROUTER_MAP[key]
class ContractManager:
"""Contract-specific logic for FlashArbitrageV2. Connection and signing are
delegated to a Web3Client instance."""
def __init__(self, client: Optional[Web3Client] = None):
try:
contract_address = os.environ["FLASH_ARBITRAGE_CONTRACT_ADDRESS"]
except KeyError as exc:
raise RuntimeError(
f"ContractManager requires {exc.args[0]} to be set — "
"on-chain commands (/chainstatus, /owner, /execute, "
"/withdraw, /withdraweth) will not work until it is."
) from exc
self.client = client or get_web3_client()
self.w3 = self.client.w3
self.contract = self.w3.eth.contract(
address=Web3.to_checksum_address(contract_address),
abi=FLASH_ARBITRAGE_V2_ABI,
)
self.assets = AssetRegistry(self.w3)
# --- read calls ---------------------------------------------------
def get_owner(self) -> str:
return self.contract.functions.owner().call()
def get_aave_pool(self) -> str:
return self.contract.functions.aavePool().call()
def get_eth_balance(self) -> Decimal:
wei = self.w3.eth.get_balance(self.contract.address)
return Decimal(wei) / Decimal(10**18)
# --- write calls ----------------------------------------------------
# Gated on-chain by onlyOwner. The signing wallet used by Web3Client MUST be
# the same address as the contract's owner or every call below reverts.
def start_arbitrage(
self,
asset,
amount,
router_buy,
router_sell,
intermediate_token,
min_intermediate_out,
min_final_out,
min_profit,
) -> str:
"""Matches the deployed 8-arg startArbitrage exactly."""
fn = self.contract.functions.startArbitrage(
Web3.to_checksum_address(asset),
amount,
Web3.to_checksum_address(router_buy),
Web3.to_checksum_address(router_sell),
Web3.to_checksum_address(intermediate_token),
min_intermediate_out,
min_final_out,
min_profit,
)
return self.client.send(fn)
def start_arbitrage_by_name(
self,
asset: str,
human_amount: Decimal | float | int,
dex_buy: str,
dex_sell: str,
intermediate_token: str,
min_intermediate_out: int,
min_final_out: int,
min_profit: int,
) -> str:
"""Convenience wrapper: resolves DEX names via ROUTER_MAP and scales
human_amount via AssetRegistry."""
router_buy = resolve_router(dex_buy)
router_sell = resolve_router(dex_sell)
amount = self.assets.scale_amount(asset, human_amount)
return self.start_arbitrage(
asset, amount,
router_buy, router_sell,
intermediate_token,
min_intermediate_out, min_final_out, min_profit,
)
def withdraw(self, token: str, amount: Optional[int] = None) -> str:
"""Withdraw the FULL balance of `token` to the contract owner.
The deployed FlashArbitrageV2.withdraw takes ONLY (address token) and
always sweeps the full balance to owner. `amount` is accepted for
backward compatibility with older callers but is IGNORED on-chain — the
previous 2-arg encoding reverted with a selector mismatch and could not
recover funds. VERIFY against your deployed contract on BscScan.
"""
if amount is not None:
logger.info(
"ContractManager.withdraw: `amount` is ignored — the deployed "
"1-arg withdraw sweeps the full %s balance to owner.", token,
)
fn = self.contract.functions.withdraw(Web3.to_checksum_address(token))
return self.client.send(fn)
def withdraw_eth(self) -> str:
fn = self.contract.functions.withdrawETH()
return self.client.send(fn)
class ContractManagerV3:
"""Contract-specific logic for FlashArbitrageV3 (mixed V2/V3 legs) — a
SEPARATE, optional deployment from ContractManager's FlashArbitrageV2.
Constructing this does NOT require FLASH_ARBITRAGE_CONTRACT_ADDRESS; it
requires FLASH_ARBITRAGE_V3_CONTRACT_ADDRESS instead, and is only ever
constructed when that's actually set (see get_contract_manager_v3())."""
def __init__(self, client: Optional[Web3Client] = None):
try:
contract_address = os.environ["FLASH_ARBITRAGE_V3_CONTRACT_ADDRESS"]
except KeyError as exc:
raise RuntimeError(
f"ContractManagerV3 requires {exc.args[0]} to be set — deploy "
"Contrect/FlashArbitrageV3.sol yourself first, then set this."
) from exc
self.client = client or get_web3_client()
self.w3 = self.client.w3
self.contract = self.w3.eth.contract(
address=Web3.to_checksum_address(contract_address),
abi=FLASH_ARBITRAGE_V3_ABI,
)
self.assets = AssetRegistry(self.w3)
def get_owner(self) -> str:
return self.contract.functions.owner().call()
def start_arbitrage_mixed(
self,
asset,
amount,
router_buy, buy_is_v3: bool, buy_fee_tier: int,
router_sell, sell_is_v3: bool, sell_fee_tier: int,
intermediate_token,
min_intermediate_out,
min_final_out,
min_profit,
) -> str:
fn = self.contract.functions.startArbitrageMixed(
Web3.to_checksum_address(asset),
amount,
Web3.to_checksum_address(router_buy), bool(buy_is_v3), int(buy_fee_tier),
Web3.to_checksum_address(router_sell), bool(sell_is_v3), int(sell_fee_tier),
Web3.to_checksum_address(intermediate_token),
min_intermediate_out,
min_final_out,
min_profit,
)
return self.client.send(fn)
def withdraw(self, token: str) -> str:
fn = self.contract.functions.withdraw(Web3.to_checksum_address(token))
return self.client.send(fn)
def withdraw_eth(self) -> str:
fn = self.contract.functions.withdrawETH()
return self.client.send(fn)
_contract_manager: Optional[ContractManager] = None
_contract_manager_v3: Optional[ContractManagerV3] = None
_contract_manager_v3_unavailable = False
def get_contract_manager() -> ContractManager:
global _contract_manager
if _contract_manager is None:
_contract_manager = ContractManager()
return _contract_manager
def get_contract_manager_v3() -> Optional[ContractManagerV3]:
"""Returns None (never raises) when FLASH_ARBITRAGE_V3_CONTRACT_ADDRESS
isn't set — this is an opt-in feature, unlike get_contract_manager()'s
required V2 contract. Cached the same way, including the "unavailable"
outcome, so a missing env var doesn't retry construction every call."""
global _contract_manager_v3, _contract_manager_v3_unavailable
if _contract_manager_v3 is not None:
return _contract_manager_v3
if _contract_manager_v3_unavailable:
return None
try:
_contract_manager_v3 = ContractManagerV3()
return _contract_manager_v3
except RuntimeError as exc:
logger.info("[ContractManagerV3] not available: %s", exc)
_contract_manager_v3_unavailable = True
return None