File size: 40,861 Bytes
9317c45 115a637 9317c45 115a637 9317c45 1a172d4 9317c45 1a172d4 9317c45 1a172d4 9317c45 cee9053 115a637 9317c45 cee9053 9317c45 f4c1d18 9317c45 f4c1d18 9317c45 cee9053 9317c45 f4c1d18 9317c45 f4c1d18 9317c45 cee9053 9317c45 cee9053 9317c45 1a172d4 9317c45 1a172d4 9317c45 f98ef2b 9317c45 f4c1d18 9317c45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 | """
modules/local_executor.py — Garden Angel Local On-Chain Execution v1.6
──────────────────────────────────────────────────────────────────────────────
v1.6 — modules/oracle.py (the Cloudflare Worker relay client) is deleted;
LocalExecutor is now the only execute_trade() implementation, no more
fallback. ExecutionResult moves to modules/execution_types.py (import
updated below) so this file doesn't import a now-deleted module.
v1.5 (2026-07-11) — FIX/DIAGNOSTIC: both legs' getAmountsOut() quotes are now
pinned to the SAME explicit block_identifier (read once via
w3.eth.block_number at the top of _execute_sync) instead of each
independently resolving "latest". The block number is now embedded in
both skip-reason messages. Prompted by production evidence that doesn't
add up under the old code: two consecutive WBTC/USDT rejections 82
seconds apart logged byte-identical quotes ("expected back $1,398.00 on
$1,464.74 in, fee $1.32") even though scanner.py's own
reserve_block_ts tracking showed the sell-leg pool had genuinely traded
in between — i.e. this method's "live" quote was not actually reflecting
current chain state at the time it ran. Pinning the block closes one
possible cause (the two legs racing against different blocks a few
seconds apart) outright. It does not by itself rule out the other:
BSC_RPC_URL is a public free-tier Ankr endpoint (see this deployment's
config), and some free-tier RPC gateways cache eth_call responses
server-side independent of the requested block tag. If a future skip
message still shows the SAME block number across two attempts more than
a few seconds apart on BSC (~3s block time), that's on-the-record proof
the RPC endpoint itself is serving stale reads and BSC_RPC_URL needs to
move off the free tier — not something this file can fix on its own.
See scanner.py's matching v18.21 changelog for a related, independently
confirmed loan-sizing bug found while chasing this (the loan-liquidity
cap was applied against each pool's TOTAL two-sided liquidity instead of
the single reserve side actually being traded against, sizing every
loan at ~2x the intended percentage of real pool depth).
v1.4 (2026-07-10) — NEW: optional `qwen=` constructor param
(modules/qwen_client.py). When configured (QWEN_API_KEY set as a Space
secret), a CONFIRMED on-chain execution's Telegram alert gets a short
AI-generated context note appended — e.g. flagging anything unusual
about the pair/spread. Explicitly advisory-only, confirmed with the
operator: Qwen is only ever asked AFTER the trade has already executed
on-chain, so it can never influence the BUY/HOLD decision, sizing, or
whether execute_trade() runs — all unchanged, still 100% the existing
spread/fee/liquidity math and the on-chain minProfit gate. Best-effort:
any Qwen failure/timeout just means no commentary that cycle — the
plain trade alert still sends exactly as it did before this existed.
v1.3 (2026-07-10) — FIX: the pre-flight go/no-go check was priced as if the
loan principal were at risk, when startArbitrage is an ATOMIC flash-loan
round trip — a trade that lands below the on-chain minProfit gate reverts
entirely, costing only gas (cents on BSC), never the loan. The old check
demanded the DOUBLE worst-case slippage haircut still beat amount_in
(an effective ~1%+ spread after DEX fees) before even attempting — a bar
confirmed in production to reject essentially everything, including
spreads a real arb bot would happily race for. Now: attempt whenever the
EXPECTED live quotes clear the caller's profit floor after the 0.09%
flash-loan fee; keep the worst-case haircuts on minIntermediateOut/
minFinalOut (execution protection); set on-chain minProfit to the
caller's floor so the contract atomically guarantees "net at least the
floor, or revert for pennies." Also: skip-reasons now log in USD instead
of raw wei, so log lines are readable at a glance.
v1.2 (2026-07-10) — NEW: optional `telegram=` constructor param. When a
TelegramClient is passed in, a CONFIRMED on-chain execution fires an
explicit "✅ LIVE TRADE EXECUTED" alert with the tx hash/BscScan link —
deliberately separate from ReportChannel's every-scan reporting (most
users leave that off, since it's noisy) so a real trade is never buried.
Best-effort: a notification failure never affects the already-returned
trade result. See bot.py's matching change (passes self._tg through).
v1.1 (2026-07-10) — FIX: BSC's "WBTC" scan pair is actually BTCB (Binance-Peg
Bitcoin), a different token than Ethereum's real WBTC — constants.py's
BSC_TOKENS keys it "BTCB", not "WBTC". _resolve_token() aliased BNB→WBNB
but had no equivalent WBTC→BTCB alias, so a live BSC WBTC BUY signal
(confirmed in production: pancakeswap, net +$493.54) failed to resolve
and was silently dropped — silently because execute_trade()'s
`except ValueError` branch also didn't log anything. Both are fixed:
WBTC now aliases to BTCB, and every resolution failure is logged at
ERROR so this can never fail invisibly again.
Purpose: this is "fix hunt". Until now, every BUY signal Scanner computed was
handed to OracleClient, which POSTs to a third-party Cloudflare Worker
(garden-angel-production.elghaly.workers.dev/execute) to actually execute the
trade on-chain. That Worker's /execute route has never been shown to work in
this deployment — gac-defi-bot's src/index.js /execute-signal handler (the
Worker-side counterpart) fails closed with 403/501 (see that repo's
SECURITY_AUDIT.md), so effectively every execute_trade() call against it has
returned confirmed=False. Net effect: this bot has been "hunting" — computing
real BUY signals — for as long as it's been deployed, while confirming
approximately zero of them on-chain, because the only thing standing between
"found an opportunity" and "executed it" was a Worker endpoint that was never
wired up on the other end.
LocalExecutor removes that dependency entirely. It implements the exact same
interface OracleClient exposes — async execute_trade(...) -> ExecutionResult,
importing the identical dataclass from modules/oracle.py so callers can't
tell the two apart — but instead of an HTTP round trip to a third-party
Worker, it calls ContractManager.start_arbitrage() directly against the
FlashArbitrageV2 contract this deployment already owns and controls on BSC —
the same contract the Wallet tab already reads from.
Wired in from bot.py, opt-in only:
LOCAL_EXECUTION_ENABLED=true — required. Unset/false leaves bot.py's
existing OracleClient (Worker-based)
path completely unchanged; nothing in
this file runs until this is explicitly
turned on.
DRY_RUN=true — respected exactly like the rest of
this deployment (PayoutManager, etc.):
LIVE router quotes are still fetched
so the numbers are real, but no
transaction is ever built, signed, or
broadcast. Returns confirmed=False with
a clear "(dry run)" message so Scanner
never credits a dry-run as a real
execution.
Safety properties:
- EVM-only, hard-enforced. A `chain` kwarg other than "ARB" or "BSC" (or
unset — every call site targets Arbitrum today, "BSC" kept only as a
legacy-compatible value) is refused up front with confirmed=False.
This never silently attempts an execution against the wrong chain's
contract/router registry. (v1.6 FIX, 2026-07-15 — this note previously
said "BSC-only", stale since the ARB migration; the code below has
accepted ARB since that migration, only the docstring hadn't caught up.)
- Every write goes through the SAME ContractManager/Web3Client the Wallet
tab already uses for reads — one signing key (PAYOUT_PRIVATE_KEY), one
verified RPC resolution path (see web3_client.py v1.2), no second,
divergent execution path to keep in sync.
- Slippage-protected quoting: minIntermediateOut/minFinalOut are computed
from LIVE getAmountsOut() router quotes at execution time — not from
Scanner's pre-trade spread estimate — with a SLIPPAGE_BPS haircut
(constants.py CFG["SLIPPAGE_BPS"], default 50 = 0.50%) applied before
being sent on-chain.
- minProfit is the caller's own floor (min_profit_usd/net_profit_usd —
never invented, never loosened to whatever quotes happen to show),
enforced ATOMICALLY by the contract: the flash-loan round trip either
nets at least the floor or the entire transaction reverts, costing only
gas (a few cents on Arbitrum), never principal. The pre-flight check
(v1.3) only skips trades whose EXPECTED live quotes can't clear the
floor after the 0.05% flash-loan fee (AAVE_FLASHLOAN_FEE_BPS=5, see
constants.py — this note previously said 0.09%, out of sync with that
constant and with this file's own error-message text below) — a
doomed-by-construction tx is never sent, but a genuine opportunity is
attempted even though it might lose the race and revert for pennies.
- Never fabricates a tx_hash or a realized_profit_usd. A transaction that
reverts (receipt.status == 0) is reported confirmed=False. This module
does not attempt to decode a realized-profit event — the deployed
FlashArbitrageV2's exact event ABI has not been independently confirmed
— so realized_profit_usd is left None on success; Scanner's
_notify_payout() already falls back to its own pre-trade estimate
whenever realized_profit_usd is None, so this is an existing, safe
contract, not a new gap.
- Every blocking web3.py call (getAmountsOut, build_transaction, sign,
send_raw_transaction, wait_for_transaction_receipt) runs inside
asyncio.to_thread() — execute_trade() is an async method called from
Scanner's async _notify_payout(), and web3.py's HTTPProvider is fully
synchronous; calling it directly would block the whole bot's event loop
(Telegram polling, keep-alive pings, other in-flight scans) for the
entire duration of an on-chain round trip.
Required env vars (only exercised when LOCAL_EXECUTION_ENABLED=true):
FLASH_ARBITRAGE_CONTRACT_ADDRESS, PAYOUT_PRIVATE_KEY, ARB_RPC_URL
— all already required by ContractManager/Web3Client. LocalExecutor
introduces no new required env vars beyond LOCAL_EXECUTION_ENABLED
and the existing DRY_RUN flag documented above.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any, Optional
from web3 import Web3
from modules.contract_manager import (
ContractManager, get_contract_manager, resolve_router,
get_contract_manager_v3, resolve_v3_router,
UNISWAP_V3_QUOTER_V2,
)
from modules.execution_types import ExecutionResult
logger = logging.getLogger("garden_angel.local_executor")
ROUTER_QUOTE_ABI = [
{
"inputs": [
{"name": "amountIn", "type": "uint256"},
{"name": "path", "type": "address[]"},
],
"name": "getAmountsOut",
"outputs": [{"name": "amounts", "type": "uint256[]"}],
"stateMutability": "view",
"type": "function",
}
]
# Uniswap V3 QuoterV2 — same simulate-via-eth_call pattern as
# ROUTER_QUOTE_ABI above, used only for the pre-flight go/no-go check on a
# V3-involving leg. See modules/scanner.py's identical ABI/usage for the
# discovery-side counterpart.
V3_QUOTER_ABI = [
{
"inputs": [
{
"components": [
{"name": "tokenIn", "type": "address"},
{"name": "tokenOut", "type": "address"},
{"name": "amountIn", "type": "uint256"},
{"name": "fee", "type": "uint24"},
{"name": "sqrtPriceLimitX96", "type": "uint160"},
],
"name": "params",
"type": "tuple",
}
],
"name": "quoteExactInputSingle",
"outputs": [
{"name": "amountOut", "type": "uint256"},
{"name": "sqrtPriceX96After", "type": "uint160"},
{"name": "initializedTicksCrossed", "type": "uint32"},
{"name": "gasEstimate", "type": "uint256"},
],
"stateMutability": "nonpayable",
"type": "function",
}
]
_DEFAULT_SLIPPAGE_BPS = 50 # 0.50% — matches constants.py CFG["SLIPPAGE_BPS"]
_RECEIPT_TIMEOUT_SECS = 180 # matches the rest of this deployment's fund-moving-call budget
def _bps_haircut(amount: int, bps: int) -> int:
"""amount * (1 - bps/10000), integer-safe (no float rounding on-chain-bound values)."""
return (amount * (10_000 - bps)) // 10_000
class _DryRunSkip(Exception):
"""Internal signal only — the dry-run path never reaches a real send/receipt."""
class LocalExecutor:
"""
Drop-in replacement for OracleClient. Same execute_trade() signature
(plus additive optional kwargs) and the same ExecutionResult return
type — no HTTP, no third party, calls ContractManager.start_arbitrage()
directly.
"""
def __init__(
self,
contract_manager: Optional[ContractManager] = None,
dry_run: bool = True,
slippage_bps: int = _DEFAULT_SLIPPAGE_BPS,
token_registry: Optional[dict] = None,
telegram: Optional[Any] = None,
qwen: Optional[Any] = None,
) -> None:
self._cm = contract_manager or get_contract_manager()
self._dry_run = bool(dry_run)
self._slippage_bps = int(slippage_bps)
# Optional TelegramClient (duck-typed via Any to avoid importing
# modules.telegram_client here — this module has no other reason
# to depend on it). When set, a CONFIRMED on-chain execution fires
# an explicit alert — deliberately separate from ReportChannel's
# every-scan reporting (which most users leave off, since it's
# noisy) so "a real trade just executed" is never buried in scan
# spam. Best-effort: a notification failure never affects the
# trade result already returned to the caller.
self._telegram = telegram
# v1.4 — optional QwenClient (modules/qwen_client.py). Advisory
# ONLY: called after a trade is already confirmed on-chain, purely
# to append a short context note to the Telegram alert above. Can
# never influence the trade that already happened. Inert/None-safe
# if not passed or not configured (QWEN_API_KEY unset).
self._qwen = qwen
if token_registry is None:
from constants import BSC_TOKENS # single source of truth for BSC token addresses
token_registry = BSC_TOKENS
self._tokens = {
sym.upper(): Web3.to_checksum_address(info["address"])
for sym, info in token_registry.items()
}
logger.warning(
"[LocalExecutor] ACTIVE — BUY signals will be executed DIRECTLY "
"on-chain against %s via ContractManager, bypassing the "
"Cloudflare Worker /execute path entirely. dry_run=%s "
"slippage_bps=%d",
self._cm.contract.address, self._dry_run, self._slippage_bps,
)
def _resolve_token(self, symbol: str) -> str:
sym = symbol.upper()
# Migrated 2026-07 to Arbitrum One. Native gas token is ETH, which has
# no ERC-20 address; the contract only ever moves wrapped WETH. WBTC on
# Arbitrum is real WBTC (8 decimals), so no BTCB-style alias is needed.
if sym in ("ETH", "WETH"):
sym = "WETH"
if sym not in self._tokens:
raise ValueError(
f"LocalExecutor: unknown token symbol '{symbol}' — not in "
f"ARB_TOKENS. Known: {', '.join(sorted(self._tokens))}."
)
return self._tokens[sym]
async def execute_trade(
self,
base_asset: str,
stable_asset: str,
target_dex: str,
amount: float,
net_profit_usd: float,
sell_dex: Optional[str] = None,
chain: Optional[str] = None,
min_profit_usd: Optional[float] = None,
buy_fee_tier: int = 0,
sell_fee_tier: int = 0,
) -> ExecutionResult:
t0 = time.monotonic()
if chain and chain.upper() not in ("ARB", "BSC"):
return ExecutionResult(
confirmed=False,
error=f"LocalExecutor (EVM) runs on Arbitrum One, got chain={chain!r}. "
f"Solana signals are handled by modules/solana_arb.py.",
duration_ms=(time.monotonic() - t0) * 1000,
)
if not sell_dex:
return ExecutionResult(
confirmed=False,
error="LocalExecutor requires sell_dex (the exit-leg DEX) "
"— caller did not provide one.",
duration_ms=(time.monotonic() - t0) * 1000,
)
# Uniswap V3 leg(s) — a SEPARATE contract/path (FlashArbitrageV3.sol,
# see that file's own header for why FlashArbitrageV2 can't run
# this). Dispatched here rather than woven into the V2-only path
# below so a live-trading deployment's existing, already-proven V2
# execution is completely untouched by this addition. The common
# case (buy_fee_tier == sell_fee_tier == 0, i.e. every trade before
# this existed) falls straight through to the unchanged code below.
if buy_fee_tier or sell_fee_tier:
return await self._execute_trade_mixed(
base_asset, stable_asset, target_dex, sell_dex, amount,
net_profit_usd, min_profit_usd, buy_fee_tier, sell_fee_tier, t0,
)
try:
stable_addr = self._resolve_token(stable_asset)
base_addr = self._resolve_token(base_asset)
router_buy = resolve_router(target_dex)
router_sell = resolve_router(sell_dex)
except ValueError as exc:
# FIX — this branch previously returned without logging anything,
# so a token/router resolution failure (e.g. the WBTC/BTCB gap
# this exact bug fixed) was completely silent: no error line in
# the HF Space console, nothing to grep for. A real BUY signal
# could vanish here with zero trace. Now logged at ERROR so a
# resolution failure is always visible, same as every other
# failure path in this method.
logger.error(
"[LocalExecutor] Token/router resolution failed for "
"%s/%s on %s→%s: %s",
base_asset, stable_asset, target_dex, sell_dex, exc,
)
return ExecutionResult(
confirmed=False, error=str(exc),
duration_ms=(time.monotonic() - t0) * 1000,
)
floor_usd = min_profit_usd if min_profit_usd is not None else max(net_profit_usd, 0.0)
try:
tx_hash, realized = await asyncio.to_thread(
self._execute_sync,
stable_addr, base_addr, router_buy, router_sell,
amount, floor_usd,
)
except _DryRunSkip as exc:
logger.info("[LocalExecutor] %s", exc)
return ExecutionResult(
confirmed=False, error=f"(dry run) {exc}",
duration_ms=(time.monotonic() - t0) * 1000,
)
except Exception as exc:
duration_ms = (time.monotonic() - t0) * 1000
logger.error(
"[LocalExecutor] Execution failed for %s/%s on %s→%s: %s",
base_asset, stable_asset, target_dex, sell_dex, exc,
)
return ExecutionResult(
confirmed=False, error=str(exc)[:300],
duration_ms=duration_ms,
)
duration_ms = (time.monotonic() - t0) * 1000
logger.info(
"[LocalExecutor] Execution confirmed on-chain in %.0fms — tx=%s",
duration_ms, tx_hash,
)
await self._notify_confirmed(
tx_hash, base_asset, stable_asset, target_dex, sell_dex,
amount, net_profit_usd, realized,
)
return ExecutionResult(
confirmed=True,
tx_hash=tx_hash,
realized_profit_usd=realized,
status_raw="confirmed",
duration_ms=duration_ms,
)
# ------------------------------------------------------------------
# Uniswap V3 mixed-leg execution — separate contract, opt-in only.
# ------------------------------------------------------------------
async def _execute_trade_mixed(
self,
base_asset: str, stable_asset: str,
target_dex: str, sell_dex: str,
amount: float, net_profit_usd: float, min_profit_usd: Optional[float],
buy_fee_tier: int, sell_fee_tier: int,
t0: float,
) -> ExecutionResult:
"""Mirrors execute_trade()'s V2-only path above, but against
FlashArbitrageV3.sol (a SEPARATE, independently-deployed contract —
see that file's own header). Fails safe and explicitly: if the
operator hasn't deployed+configured it, this returns confirmed=False
with a clear reason, exactly like every other resolution failure in
this module — it never falls back to silently skipping the trade
without a trace, and it never attempts to force the V2-only path
with mismatched router interfaces.
"""
import constants # local import — same lazy-import convention _resolve_token uses
if not constants.v3_execution_enabled():
return ExecutionResult(
confirmed=False,
error="Uniswap V3 leg found but V3_EXECUTION_ENABLED is not "
"'true' — reported only, not executed. Deploy "
"Contrect/FlashArbitrageV3.sol and set "
"FLASH_ARBITRAGE_V3_CONTRACT_ADDRESS + "
"V3_EXECUTION_ENABLED=true to go live on this leg.",
duration_ms=(time.monotonic() - t0) * 1000,
)
cm_v3 = get_contract_manager_v3()
if cm_v3 is None:
return ExecutionResult(
confirmed=False,
error="Uniswap V3 leg found but FLASH_ARBITRAGE_V3_CONTRACT_ADDRESS "
"is unset/unreachable — deploy Contrect/FlashArbitrageV3.sol "
"yourself first (see that file's header).",
duration_ms=(time.monotonic() - t0) * 1000,
)
try:
stable_addr = self._resolve_token(stable_asset)
base_addr = self._resolve_token(base_asset)
router_buy = resolve_v3_router(target_dex) if buy_fee_tier else resolve_router(target_dex)
router_sell = resolve_v3_router(sell_dex) if sell_fee_tier else resolve_router(sell_dex)
except ValueError as exc:
logger.error(
"[LocalExecutor] V3 token/router resolution failed for "
"%s/%s on %s→%s: %s",
base_asset, stable_asset, target_dex, sell_dex, exc,
)
return ExecutionResult(
confirmed=False, error=str(exc),
duration_ms=(time.monotonic() - t0) * 1000,
)
floor_usd = min_profit_usd if min_profit_usd is not None else max(net_profit_usd, 0.0)
try:
tx_hash, realized = await asyncio.to_thread(
self._execute_sync_mixed,
cm_v3, stable_addr, base_addr,
router_buy, bool(buy_fee_tier), int(buy_fee_tier),
router_sell, bool(sell_fee_tier), int(sell_fee_tier),
amount, floor_usd,
)
except _DryRunSkip as exc:
logger.info("[LocalExecutor] %s", exc)
return ExecutionResult(
confirmed=False, error=f"(dry run) {exc}",
duration_ms=(time.monotonic() - t0) * 1000,
)
except Exception as exc:
duration_ms = (time.monotonic() - t0) * 1000
logger.error(
"[LocalExecutor] V3 execution failed for %s/%s on %s→%s: %s",
base_asset, stable_asset, target_dex, sell_dex, exc,
)
return ExecutionResult(
confirmed=False, error=str(exc)[:300],
duration_ms=duration_ms,
)
duration_ms = (time.monotonic() - t0) * 1000
logger.info(
"[LocalExecutor] V3 execution confirmed on-chain in %.0fms — tx=%s",
duration_ms, tx_hash,
)
await self._notify_confirmed(
tx_hash, base_asset, stable_asset, target_dex, sell_dex,
amount, net_profit_usd, realized,
)
return ExecutionResult(
confirmed=True,
tx_hash=tx_hash,
realized_profit_usd=realized,
status_raw="confirmed",
duration_ms=duration_ms,
)
def _execute_sync_mixed(
self,
cm_v3,
stable_addr: str, base_addr: str,
router_buy: str, buy_is_v3: bool, buy_fee_tier: int,
router_sell: str, sell_is_v3: bool, sell_fee_tier: int,
amount: float, floor_usd: float,
) -> tuple[str, Optional[float]]:
"""Runs inside asyncio.to_thread(). Mirrors _execute_sync's pre-
flight/slippage/on-chain-minProfit structure exactly, but quotes a
V3 leg via QuoterV2.quoteExactInputSingle instead of getAmountsOut()
(V3 has no two-address `path` — it's single-hop per fee tier)."""
w3 = cm_v3.w3
amount_in = cm_v3.assets.scale_amount(stable_addr, amount)
floor_wei = cm_v3.assets.scale_amount(stable_addr, max(floor_usd, 0.0))
block_number = w3.eth.block_number
def _quote_leg(is_v3: bool, router: str, fee: int, token_in: str, token_out: str, amount_in_raw: int) -> int:
if is_v3:
quoter = w3.eth.contract(
address=Web3.to_checksum_address(UNISWAP_V3_QUOTER_V2), abi=V3_QUOTER_ABI,
)
params = (
Web3.to_checksum_address(token_in), Web3.to_checksum_address(token_out),
amount_in_raw, fee, 0,
)
# Pinned to the same explicit block as the V2 leg's
# getAmountsOut() call below (see _execute_sync's v1.5
# note on why both legs must share one consistent block).
out = quoter.functions.quoteExactInputSingle(params).call(
block_identifier=block_number
)
return int(out[0])
router_c = w3.eth.contract(address=router, abi=ROUTER_QUOTE_ABI)
quote = router_c.functions.getAmountsOut(
amount_in_raw, [token_in, token_out]
).call(block_identifier=block_number)
return int(quote[-1])
expected_intermediate = _quote_leg(buy_is_v3, router_buy, buy_fee_tier, stable_addr, base_addr, amount_in)
min_intermediate_out = _bps_haircut(expected_intermediate, self._slippage_bps)
expected_final = _quote_leg(sell_is_v3, router_sell, sell_fee_tier, base_addr, stable_addr, expected_intermediate)
min_final_out = _bps_haircut(expected_final, 2 * self._slippage_bps)
from constants import AAVE_FLASHLOAN_FEE_BPS
flash_fee_wei = (amount_in * AAVE_FLASHLOAN_FEE_BPS) // 10_000
expected_profit_wei = expected_final - amount_in - flash_fee_wei
dec = cm_v3.assets.decimals(stable_addr)
def _usd(wei: int) -> str:
return f"${wei / 10 ** dec:,.2f}"
if expected_profit_wei <= 0:
raise RuntimeError(
f"V3 live quotes unprofitable after the 0.05% flash-loan fee "
f"(expected back {_usd(expected_final)} on {_usd(amount_in)} in, "
f"fee {_usd(flash_fee_wei)}) — spread too thin, skipping."
)
if floor_wei > 0 and expected_profit_wei < floor_wei:
raise RuntimeError(
f"V3 expected profit {_usd(expected_profit_wei)} is below the "
f"configured floor {_usd(floor_wei)} — skipping rather than "
"sending a trade that would revert on the contract's own "
"minProfit gate."
)
min_profit = floor_wei if floor_wei > 0 else 1
if self._dry_run:
raise _DryRunSkip(
f"would call startArbitrageMixed(asset={stable_addr}, "
f"amount={amount_in}, routerBuy={router_buy} v3={buy_is_v3} "
f"fee={buy_fee_tier}, routerSell={router_sell} v3={sell_is_v3} "
f"fee={sell_fee_tier}, intermediateToken={base_addr}, "
f"minIntermediateOut={min_intermediate_out}, "
f"minFinalOut={min_final_out}, minProfit={min_profit}) — "
f"DRY_RUN=true, nothing broadcast."
)
tx_hash = cm_v3.start_arbitrage_mixed(
asset=stable_addr,
amount=amount_in,
router_buy=router_buy, buy_is_v3=buy_is_v3, buy_fee_tier=buy_fee_tier,
router_sell=router_sell, sell_is_v3=sell_is_v3, sell_fee_tier=sell_fee_tier,
intermediate_token=base_addr,
min_intermediate_out=min_intermediate_out,
min_final_out=min_final_out,
min_profit=min_profit,
)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=_RECEIPT_TIMEOUT_SECS)
if receipt.status != 1:
raise RuntimeError(f"Transaction {tx_hash} reverted on-chain (status=0).")
return tx_hash, None
async def _notify_confirmed(
self,
tx_hash: str, base_asset: str, stable_asset: str,
target_dex: str, sell_dex: str,
amount: float, net_profit_usd: float, realized: Optional[float],
) -> None:
"""Best-effort Telegram alert for a CONFIRMED execution — see
__init__'s self._telegram docstring note. Never raises: a
notification failure must never look like the trade itself
failed, since by this point it has already succeeded on-chain."""
if self._telegram is None:
return
profit_usd = realized if realized is not None else net_profit_usd
profit_line = (
f"${realized:,.2f} (realized)" if realized is not None
else f"~${net_profit_usd:,.2f} (pre-trade estimate)"
)
# FIX (2026-07-15, operator went live — DRY_RUN=false) — this linked
# to bscscan.com, a leftover from before the migration. ContractManager/
# Web3Client (self._cm) only ever operate against Arbitrum in this
# deployment (see execute_trade's own chain gate above), so every real
# tx_hash here is an Arbitrum One transaction — bscscan.com can never
# find it. This is exactly the link an operator opens the moment their
# very first live trade confirms, to verify funds actually moved.
text = (
f"✅ LIVE TRADE EXECUTED\n"
f"{base_asset}/{stable_asset} — {target_dex}→{sell_dex}\n"
f"Loan: ${amount:,.2f} | Profit: {profit_line}\n"
f"https://arbiscan.io/tx/{tx_hash}"
)
# v1.4 — advisory-only Qwen commentary, appended AFTER the trade
# already confirmed on-chain (see __init__'s self._qwen docstring
# note). approx spread_pct here is just profit/loan — a rough
# proxy for the commentary prompt, not the scanner's precise
# pre-trade spread_pct (which isn't available at this call site).
# Never allowed to delay or block the core alert: if this fails,
# times out, or isn't configured, the plain alert above still
# sends exactly as it always has.
if self._qwen is not None and getattr(self._qwen, "enabled", False):
try:
approx_spread_pct = (profit_usd / amount * 100) if amount else 0.0
commentary = await self._qwen.analyze_opportunity(
base_asset=base_asset, stable_asset=stable_asset,
buy_dex=target_dex, sell_dex=sell_dex,
spread_pct=approx_spread_pct, loan_amount=amount,
net_profit_usd=profit_usd,
)
if commentary:
text += f"\n\n🤖 {commentary}"
except Exception as exc:
logger.warning("[LocalExecutor] Qwen commentary failed (non-fatal): %s", exc)
try:
await self._telegram.safe_send(text)
except Exception as exc:
logger.warning("[LocalExecutor] Confirmed-trade Telegram alert failed: %s", exc)
def _execute_sync(
self,
stable_addr: str, base_addr: str,
router_buy: str, router_sell: str,
amount: float, floor_usd: float,
) -> tuple[str, Optional[float]]:
"""Runs inside asyncio.to_thread() — blocking web3.py calls only."""
w3 = self._cm.w3
amount_in = self._cm.assets.scale_amount(stable_addr, amount)
floor_wei = self._cm.assets.scale_amount(stable_addr, max(floor_usd, 0.0))
# v1.5 FIX/DIAGNOSTIC (2026-07-11) — pin both legs' getAmountsOut()
# calls to the SAME explicit block, and surface that block number
# in every skip/failure message. Prompted by production evidence:
# two consecutive WBTC/USDT rejections 82s apart logged
# byte-identical numbers ("expected back $1,398.00 on $1,464.74
# in, fee $1.32") even though the sell-leg pool's own
# reserve_block_ts (tracked by scanner.py) showed it had traded
# in between — i.e. the quote this method used was NOT current
# chain state at the time it ran. Two possible causes, both
# addressed by this change: (1) the two legs racing against
# different blocks if a new block lands between the calls — fixed
# by pinning both to one explicit block_identifier instead of each
# implicitly defaulting to "latest" independently; (2) the RPC
# endpoint itself (BSC_RPC_URL — a public free-tier Ankr URL per
# this deployment's config) caching eth_call responses server-side
# regardless of block tag — NOT fixable from this file, but now
# provable: if this block number stops advancing between
# consecutive failures on the same pair, that's the smoking gun,
# and BSC_RPC_URL needs to move off the free-tier endpoint.
block_number = w3.eth.block_number
buy_router = w3.eth.contract(address=router_buy, abi=ROUTER_QUOTE_ABI)
sell_router = w3.eth.contract(address=router_sell, abi=ROUTER_QUOTE_ABI)
buy_quote = buy_router.functions.getAmountsOut(
amount_in, [stable_addr, base_addr]
).call(block_identifier=block_number)
expected_intermediate = buy_quote[-1]
min_intermediate_out = _bps_haircut(expected_intermediate, self._slippage_bps)
# Quote the sell leg from the EXPECTED intermediate amount — the
# profitability decision should be made on what the quotes say
# will actually happen, not on the worst-case haircut chain.
# Pinned to the SAME block as the buy-leg quote above (see v1.5
# note) so both legs are evaluated against one consistent chain
# state instead of each independently resolving "latest".
sell_quote = sell_router.functions.getAmountsOut(
expected_intermediate, [base_addr, stable_addr]
).call(block_identifier=block_number)
expected_final = sell_quote[-1]
# minFinalOut stays worst-case (both legs slipping to their
# haircut limits) — this is EXECUTION protection, enforced by the
# router/contract on-chain, not the go/no-go decision.
min_final_out = _bps_haircut(expected_final, 2 * self._slippage_bps)
# v1.3 — the go/no-go decision. Previous versions demanded the
# DOUBLE-HAIRCUT worst case (min_final_out) still beat amount_in,
# i.e. an effective ~1%+ spread after DEX fees, before even
# attempting — a bar so high it near-never fires on real BSC
# blue-chip pairs. That caution was misplaced: startArbitrage is
# an ATOMIC flash-loan round trip. If reality lands below the
# on-chain minProfit gate at execution time, the ENTIRE
# transaction reverts — the loan never completes, no principal is
# ever at risk, and the only cost is the gas of a reverted tx
# (cents on BSC). So the right economics are: attempt whenever
# the EXPECTED quotes clear the caller's profit floor after the
# flash-loan fee, and let the atomic on-chain minProfit revert be
# the (cheap) loss-taker when the price races away. Losing that
# race occasionally costs cents; never entering it costs every
# real opportunity.
from constants import AAVE_FLASHLOAN_FEE_BPS # single source of truth (5 bps on Arbitrum)
flash_fee_wei = (amount_in * AAVE_FLASHLOAN_FEE_BPS) // 10_000
expected_profit_wei = expected_final - amount_in - flash_fee_wei
dec = self._cm.assets.decimals(stable_addr)
def _usd(wei: int) -> str:
return f"${wei / 10 ** dec:,.2f}"
if expected_profit_wei <= 0:
raise RuntimeError(
f"Live quotes unprofitable after the 0.05% flash-loan fee "
f"(expected back {_usd(expected_final)} on {_usd(amount_in)} in, "
f"fee {_usd(flash_fee_wei)}, quoted @ block {block_number}) — "
f"spread too thin, skipping."
)
if floor_wei > 0 and expected_profit_wei < floor_wei:
raise RuntimeError(
f"Expected profit {_usd(expected_profit_wei)} is below the "
f"configured floor {_usd(floor_wei)} (quoted @ block "
f"{block_number}) — skipping rather than sending a trade "
f"that would revert on the contract's own minProfit gate."
)
# On-chain minProfit = the caller's floor, enforced atomically by
# the contract: it either nets at least this or the whole flash
# loan reverts for pennies of gas. Never loosened to whatever the
# quotes happen to show — the floor is the operator's risk knob.
min_profit = floor_wei if floor_wei > 0 else 1
if self._dry_run:
raise _DryRunSkip(
f"would call startArbitrage(asset={stable_addr}, "
f"amount={amount_in}, routerBuy={router_buy}, "
f"routerSell={router_sell}, intermediateToken={base_addr}, "
f"minIntermediateOut={min_intermediate_out}, "
f"minFinalOut={min_final_out}, minProfit={min_profit}) — "
f"DRY_RUN=true, nothing broadcast."
)
tx_hash = self._cm.start_arbitrage(
asset=stable_addr,
amount=amount_in,
router_buy=router_buy,
router_sell=router_sell,
intermediate_token=base_addr,
min_intermediate_out=min_intermediate_out,
min_final_out=min_final_out,
min_profit=min_profit,
)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=_RECEIPT_TIMEOUT_SECS)
if receipt.status != 1:
raise RuntimeError(f"Transaction {tx_hash} reverted on-chain (status=0).")
# realized_profit_usd deliberately left None — see module docstring
# (no independently-confirmed event ABI to decode it from). Scanner
# falls back to its own pre-trade estimate for ledger crediting.
return tx_hash, None
_local_executor: Optional[LocalExecutor] = None
def get_local_executor() -> LocalExecutor:
global _local_executor
if _local_executor is None:
import os
_local_executor = LocalExecutor(
dry_run=os.environ.get("DRY_RUN", "false").strip().lower() == "true",
)
return _local_executor
|