File size: 18,900 Bytes
9317c45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4c1d18
 
9317c45
f4c1d18
 
 
 
 
 
 
 
 
 
 
 
 
9317c45
f4c1d18
 
9317c45
f4c1d18
 
9317c45
f4c1d18
 
 
9317c45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4c1d18
9317c45
 
f4c1d18
 
9317c45
 
f4c1d18
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
"""
modules/executor.py — ChainExecutor for On-Chain Execution
──────────────────────────────────────────────────────────

Signs and broadcasts native-token sweep transactions with mandatory
pre-flight simulation and resilient broadcast retry logic.
"""

import concurrent.futures
import logging
import time
from typing import Any, Optional

logger = logging.getLogger(__name__)

try:
    from web3 import Web3
    try:
        from web3.middleware import geth_poa_middleware as _POA_MW
    except ImportError:
        from web3.middleware import ExtraDataToPOAMiddleware as _POA_MW

    def _to_checksum(addr: str) -> str:
        try:
            return Web3.to_checksum_address(addr)
        except AttributeError:
            return Web3.toChecksumAddress(addr)

    def _raw_tx(signed) -> bytes:
        return getattr(signed, "raw_transaction", None) or signed.rawTransaction

    _WEB3_AVAILABLE = True
except ImportError:
    _WEB3_AVAILABLE = False
    _POA_MW = None


# Migrated 2026-07 to Arbitrum One. The sweep/transfer executor uses these for
# building native-token payout transactions on the configured PAYOUT_CHAIN.
_CHAIN_CFG = {
    "ARB": {
        "rpc_env": "ARB_RPC_URL",
        "rpc_default": "https://arb1.arbitrum.io/rpc",
        "rpc_fallbacks": [
            "https://arbitrum-one.publicnode.com",
            "https://rpc.ankr.com/arbitrum",
        ],
        "chain_id": 42161,
        "max_gas_gwei": 2.0,
        "native": "ETH",
        "is_poa": True,
    },
    # Legacy alias -> Arbitrum so an old PAYOUT_CHAIN=BSC can't reach a dead node.
    "BSC": {
        "rpc_env": "ARB_RPC_URL",
        "rpc_default": "https://arb1.arbitrum.io/rpc",
        "rpc_fallbacks": [
            "https://arbitrum-one.publicnode.com",
            "https://rpc.ankr.com/arbitrum",
        ],
        "chain_id": 42161,
        "max_gas_gwei": 2.0,
        "native": "ETH",
        "is_poa": True,
    },
}

GAS_LIMIT_TRANSFER = 21_000
BROADCAST_MAX_RETRIES = 4
BROADCAST_RETRY_DELAY_SEC = 2.5


def _sanitize_private_key(raw: str) -> str:
    """Clean a hot-wallet private key from environment variable."""
    if not raw:
        return ""
    key = raw.strip()
    if len(key) >= 2 and key[0] == key[-1] and key[0] in ("'", '"'):
        key = key[1:-1].strip()
    if key and not key.lower().startswith("0x"):
        key = "0x" + key
    return key


def _extract_revert_reason(exc: Exception) -> str:
    """Best-effort extraction of human-readable revert reason."""
    try:
        if exc.args and isinstance(exc.args[0], dict):
            return str(exc.args[0].get("message") or exc.args[0])
    except Exception:
        pass
    return str(exc)


def _is_transient_rpc_error(exc: Exception) -> bool:
    """True for infrastructure-level failures worth a short retry."""
    text = str(exc).lower()
    transient_markers = (
        "502", "bad gateway",
        "504", "gateway timeout",
        "503", "service unavailable",
        "timed out", "timeout",
        "connection reset", "connection aborted", "connection refused",
        "econnreset", "remote end closed", "broken pipe",
        "temporarily unavailable", "max retries exceeded",
    )
    return any(m in text for m in transient_markers)


def _is_hard_revert_error(exc: Exception) -> bool:
    """True for definitive EVM-level revert or fund/gas errors."""
    text = str(exc).lower()
    hard_revert_markers = (
        "revert", "execution reverted",
        "insufficient funds",
        "gas required exceeds allowance",
        "always failing transaction",
        "out of gas", "invalid opcode",
    )
    return any(m in text for m in hard_revert_markers)


class ChainExecutor:
    """
    Signs and broadcasts a native-token sweep transaction with full
    Simulate-Before-Strike pipeline and resilient broadcast retry.

    Private key is held in memory and never written to disk or logs.
    """

    def __init__(self, chain: str, private_key: str, rpc_url: Optional[str] = None) -> None:
        """Initialize ChainExecutor."""
        if not _WEB3_AVAILABLE:
            raise RuntimeError(
                "web3.py is required for on-chain execution: pip install web3"
            )

        clean_key = _sanitize_private_key(private_key)
        if not clean_key:
            raise ValueError("private_key must not be empty")

        self.chain = chain.upper()

        import os
        if self.chain not in ("ARB", "BSC") and os.getenv("ALLOW_ETH", "0") != "1":
            raise RuntimeError(
                f"ChainExecutor: chain='{self.chain}' rejected — this "
                f"deployment runs on Arbitrum One (ARB). Set ALLOW_ETH=1 "
                f"to enable the optional ETH side."
            )

        cfg = _CHAIN_CFG.get(self.chain, _CHAIN_CFG["ARB"])

        env_rpc = os.getenv(cfg["rpc_env"])
        candidates = [rpc_url or env_rpc or cfg["rpc_default"]]
        candidates += [u for u in cfg.get("rpc_fallbacks", []) if u not in candidates]

        self._chain_id = cfg["chain_id"]
        self._rpc_candidates = candidates
        self._w3 = None
        self._active_rpc = None

        last_exc: Optional[Exception] = None
        for rpc in candidates:
            try:
                w3 = Web3(Web3.HTTPProvider(rpc, request_kwargs={"timeout": 10}))
                if cfg["is_poa"] and _POA_MW:
                    w3.middleware_onion.inject(_POA_MW, layer=0)
                # Cheap liveness check before committing to this endpoint.
                w3.eth.chain_id
                self._w3 = w3
                self._active_rpc = rpc
                break
            except Exception as exc:
                last_exc = exc
                logger.warning(
                    "[ChainExecutor] RPC candidate %s unreachable on init "
                    "(%s) — trying next", rpc, exc,
                )

        if self._w3 is None:
            raise RuntimeError(
                f"All {len(candidates)} RPC candidate(s) for {self.chain} "
                f"failed on init. Last error: {last_exc}"
            )

        if self._active_rpc != candidates[0]:
            logger.info(
                "[ChainExecutor] Using fallback RPC for %s: %s",
                self.chain, self._active_rpc,
            )

        try:
            self._account = self._w3.eth.account.from_key(clean_key)
        except Exception as exc:
            raise ValueError(
                f"PAYOUT_PRIVATE_KEY failed to derive a valid account "
                f"(len={len(clean_key)} after sanitization). Check that it "
                f"is a 32-byte hex key, optionally 0x-prefixed, with no "
                f"extra whitespace or quotes. Underlying error: {exc}"
            ) from exc
        finally:
            del clean_key

        # v2.1 — full address logged (not just the first 12 chars) and
        # explicitly labeled "SIGNING/GAS address". Production incident:
        # this line previously truncated to "0x4c56dF1c7D..." which was
        # easy to eyeball-confuse with a similar-looking but DIFFERENT
        # PAYOUT_WALLET value also floating around in the same debug
        # session (0x2C25..., 0xe21a...) — all three addresses share the
        # "0x" + short prefix shape at a glance. The truncation also made
        # it impossible to grep/diff this log line against a funded
        # address without further digging. This address is never secret
        # (it's a public wallet address, not the private key), so logging
        # it in full has no security cost.
        logger.info(
            "[ChainExecutor] Ready on %s — SIGNING/GAS wallet (fund THIS "
            "one, not PAYOUT_WALLET): %s",
            self.chain, self._account.address,
        )

    def _rotate_rpc(self) -> bool:
        """
        Try switching to the next untried candidate RPC. Returns True if a
        healthy endpoint was found and self._w3 was swapped, False if every
        candidate has now been tried this run.
        """
        remaining = [u for u in self._rpc_candidates if u != self._active_rpc]
        for rpc in remaining:
            try:
                w3 = Web3(Web3.HTTPProvider(rpc, request_kwargs={"timeout": 10}))
                cfg = _CHAIN_CFG.get(self.chain, _CHAIN_CFG["ARB"])
                if cfg["is_poa"] and _POA_MW:
                    w3.middleware_onion.inject(_POA_MW, layer=0)
                w3.eth.chain_id
                self._w3 = w3
                self._active_rpc = rpc
                logger.warning(
                    "[ChainExecutor] Rotated %s RPC to fallback: %s",
                    self.chain, rpc,
                )
                return True
            except Exception as exc:
                logger.warning(
                    "[ChainExecutor] Fallback RPC %s also unreachable (%s)",
                    rpc, exc,
                )
        return False

    @property
    def hot_address(self) -> str:
        return self._account.address

    def is_same_address(self, other: str) -> bool:
        """
        v2.1 — case-insensitive comparison helper. Addresses are not
        case-sensitive at the protocol level (checksum casing is a display
        convention only), so comparing hot_address == some_config_value
        with a plain == is a latent bug: two representations of the same
        address can differ only in casing and compare unequal. Use this
        instead of manual string comparison anywhere PAYOUT_WALLET or any
        other address is checked against the signing address.
        """
        if not other:
            return False
        return self._account.address.strip().lower() == other.strip().lower()

    def get_balance_wei(self) -> int:
        return self._w3.eth.get_balance(self._account.address)

    def get_balance_native(self) -> float:
        return self.get_balance_wei() / 1e18

    def build_transfer_tx(
        self,
        to: str,
        amount_wei: int,
        gas_price_wei: int,
        gas_limit: int = GAS_LIMIT_TRANSFER,
    ) -> dict:
        """Build a transfer transaction."""
        nonce = self._w3.eth.get_transaction_count(self._account.address, "pending")
        return {
            "nonce": nonce,
            "to": _to_checksum(to),
            "value": amount_wei,
            "gas": gas_limit,
            "gasPrice": gas_price_wei,
            "chainId": self._chain_id,
        }

    def sign_and_send(self, tx: dict) -> str:
        """Sign tx with stored key and broadcast. Return 0x-prefixed tx_hash."""
        signed = self._w3.eth.account.sign_transaction(tx, self._account.key)
        raw = _raw_tx(signed)
        tx_bytes = self._w3.eth.send_raw_transaction(raw)
        h = tx_bytes.hex()
        return h if h.startswith("0x") else "0x" + h

    def _simulate_with_retry(
        self,
        tx: dict,
        max_retries: int = 2,
        retry_delay: float = 1.0,
    ) -> tuple[bool, str]:
        """Pre-flight simulation via eth_estimateGas with retry logic."""
        last_reason = ""
        for attempt in range(1, max_retries + 1):
            try:
                sim_tx = {k: v for k, v in tx.items() if k != "nonce"}
                estimated = self._w3.eth.estimate_gas(sim_tx)
                return True, f"simulation ok — estimated_gas={estimated}"
            except Exception as exc:
                reason = _extract_revert_reason(exc)
                last_reason = reason
                if _is_transient_rpc_error(exc) and attempt < max_retries:
                    logger.warning(
                        "[ChainExecutor] Simulation attempt %d/%d hit a "
                        "transient RPC error (%s) — rotating RPC and "
                        "retrying in %.1fs",
                        attempt, max_retries, exc, retry_delay,
                    )
                    self._rotate_rpc()
                    time.sleep(retry_delay)
                    continue
                if _is_hard_revert_error(exc):
                    return False, reason
                return False, f"unclassified simulation error: {reason}"
        return False, last_reason or "simulation failed after retries"

    def simulate_transaction(self, tx: dict) -> tuple[bool, str]:
        """Single-shot simulation without retry."""
        try:
            sim_tx = {k: v for k, v in tx.items() if k != "nonce"}
            estimated = self._w3.eth.estimate_gas(sim_tx)
            return True, f"simulation ok — estimated_gas={estimated}"
        except Exception as exc:
            return False, _extract_revert_reason(exc)

    def _sign_locally(self, tx: dict) -> bytes:
        """Local deterministic signing, safe to run concurrently."""
        signed = self._w3.eth.account.sign_transaction(tx, self._account.key)
        return _raw_tx(signed)

    def simulate_and_broadcast(
        self,
        tx: dict,
        max_retries: int = BROADCAST_MAX_RETRIES,
        retry_delay: float = BROADCAST_RETRY_DELAY_SEC,
        simulate_retries: int = 2,
        simulate_retry_delay: float = 1.0,
    ) -> dict:
        """
        Full Simulate-Before-Strike pipeline.

        Runs simulation and local signing in parallel, then broadcasts with
        transient-only retries.
        """
        with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
            sim_future = pool.submit(
                self._simulate_with_retry, tx, simulate_retries, simulate_retry_delay
            )
            sign_future = pool.submit(self._sign_locally, tx)

            sim_ok, sim_msg = sim_future.result()

            if not sim_ok:
                logger.error(
                    "[ChainExecutor] Pre-flight simulation FAILED — aborting "
                    "before broadcast: %s", sim_msg,
                )
                try:
                    sign_future.result(timeout=5)
                except Exception:
                    pass
                return {
                    "ok": False,
                    "tx_hash": None,
                    "stage": "simulate",
                    "msg": f"Simulation predicts revert: {sim_msg}",
                    "attempts": 0,
                }
            logger.info("[ChainExecutor] Pre-flight simulation passed (%s)", sim_msg)

            try:
                raw = sign_future.result()
            except Exception as exc:
                return {
                    "ok": False,
                    "tx_hash": None,
                    "stage": "sign",
                    "msg": f"Local signing failed: {exc}",
                    "attempts": 0,
                }

        last_exc: Optional[Exception] = None
        for attempt in range(1, max_retries + 1):
            try:
                tx_bytes = self._w3.eth.send_raw_transaction(raw)
                h = tx_bytes.hex()
                tx_hash = h if h.startswith("0x") else "0x" + h
                return {
                    "ok": True,
                    "tx_hash": tx_hash,
                    "stage": "broadcast",
                    "msg": "broadcast ok",
                    "attempts": attempt,
                }
            except Exception as exc:
                last_exc = exc
                if _is_transient_rpc_error(exc) and attempt < max_retries:
                    logger.warning(
                        "[ChainExecutor] Broadcast attempt %d/%d hit transient "
                        "RPC error (%s) — rotating RPC and retrying in %.1fs",
                        attempt, max_retries, exc, retry_delay,
                    )
                    self._rotate_rpc()
                    time.sleep(retry_delay)
                    continue
                logger.error(
                    "[ChainExecutor] Broadcast attempt %d/%d failed (%s)",
                    attempt, max_retries, exc,
                )
                break

        return {
            "ok": False,
            "tx_hash": None,
            "stage": "broadcast",
            "msg": f"Broadcast failed after {max_retries} attempt(s): {last_exc}",
            "attempts": max_retries,
        }

    def wait_receipt(self, tx_hash: str, timeout: int = 120) -> dict:
        """Poll for receipt until confirmed or timeout."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            try:
                r = self._w3.eth.get_transaction_receipt(tx_hash)
                if r is not None:
                    return dict(r)
            except Exception:
                pass
            time.sleep(3)
        raise TimeoutError(f"Receipt not found within {timeout}s: {tx_hash}")

    def sweep(
        self,
        cold_wallet: str,
        gas_engine: Any,
        reserve_txcount: int = 20,
        gas_multiplier: float = 1.0,
        wait: bool = True,
        timeout: int = 120,
    ) -> dict:
        """Full sweep pipeline: calculate amount → build → sign → broadcast → confirm."""
        gas_price_wei = int(gas_engine.get_gas_price_wei() * gas_multiplier)
        balance_wei = self.get_balance_wei()
        tx_gas_cost = gas_price_wei * GAS_LIMIT_TRANSFER
        reserve_wei = gas_engine.gas_reserve_wei(reserve_txcount)
        amount_wei = balance_wei - tx_gas_cost - reserve_wei

        if amount_wei <= 0:
            raise ValueError(
                f"Insufficient balance. "
                f"balance={balance_wei} tx_gas={tx_gas_cost} reserve={reserve_wei}"
            )

        tx = self.build_transfer_tx(cold_wallet, amount_wei, gas_price_wei)
        outcome = self.simulate_and_broadcast(tx)
        if not outcome["ok"]:
            raise RuntimeError(
                f"Sweep aborted at stage='{outcome['stage']}': {outcome['msg']}"
            )
        tx_hash = outcome["tx_hash"]

        logger.info(
            "[ChainExecutor] Sweep sent: %d wei → %s... | %.2f Gwei | tx=%s...",
            amount_wei, cold_wallet[:12], gas_price_wei / 1e9, tx_hash[:20],
        )

        result = {
            "tx_hash": tx_hash,
            "amount_wei": amount_wei,
            "amount_native": amount_wei / 1e18,
            "gas_price_wei": gas_price_wei,
            "gas_price_gwei": round(gas_price_wei / 1e9, 4),
            "confirmed": False,
            "block_number": None,
            "status": None,
        }

        if wait:
            try:
                receipt = self.wait_receipt(tx_hash, timeout)
                result["confirmed"] = True
                result["block_number"] = receipt.get("blockNumber")
                result["status"] = receipt.get("status")
            except TimeoutError:
                logger.warning(
                    "[ChainExecutor] Receipt timeout — tx still pending: %s...",
                    tx_hash[:20],
                )

        return result