"""
ZYNOST PAY — MERCHANT FUND SWEEP (self-serve, no Zynost backend access needed)

This is the tool a REAL merchant integrating Zynost Pay runs to consolidate
their received crypto payments into one wallet. Unlike Zynost's own internal
sweep scripts (which query the database directly, since we own that data),
this one talks to nothing but:
  - The public Zynost Pay API, authenticated with YOUR OWN API key
    (the same one you copied from the dashboard's API & Webhooks page).
  - Public blockchain RPC nodes (no API key needed there).
Nothing here requires a Zynost login, Railway access, or any credential
beyond your own API key and your own wallet mnemonic.

Run this YOURSELF, in your OWN terminal (never through an AI assistant,
never paste its prompts/output anywhere) — it handles a real private key
derived from your mnemonic and can move real money.

    pip install -r requirements-offline-tools.txt
    python sweep_via_api.py

What it does, step by step:
  1. Asks for your Zynost Pay API key (starts with zg_live_...).
  2. Asks for your 24-word mnemonic — the same one you used when you set
     your payout_evm_xpub. Typed VISIBLY (hidden/masked input proved
     unreliable across terminals in testing — this only ever runs in your
     own private terminal session, so make sure no one else can see your
     screen). Verifies it derives to your EXACT on-file xpub — fetched
     live from GET /v1/merchant/info — before touching anything.
  3. Prints a fixed "gas reserve" address (same one every run — see
     GAS_RESERVE_EVM_INDEX). Fund THIS ONE address once per chain you use
     and the script auto-tops-up whichever order address is short on gas
     before sweeping it — every order gets its own unique, never-reused
     address (needed for on-chain payment attribution), which used to mean
     a separate manual top-up for every single one.
  4. Asks for the ONE destination EVM address you want everything
     consolidated into.
  5. Calls GET /v1/orders?status=paid with your API key, which returns
     every paid order's address, chain, asset, and derivation index.
  6. For each one, re-derives that exact order's private key from your
     mnemonic (m/44'/60'/0'/0/{index} — the standard Ethereum path Zynost
     Pay uses for every merchant), checks the REAL live on-chain balance
     (never trusts what the API says was paid), and — if there's enough
     native gas at that address — signs and broadcasts a transfer of the
     full stablecoin balance straight to your destination. If there isn't,
     it first tries auto-topping-up from the gas-reserve address above and
     retries in the same pass.
  7. Safe to re-run any time: already-swept addresses just read as zero
     balance and get skipped automatically.
"""
import asyncio
import hashlib
import hmac
import os
import sys
from datetime import datetime, timezone

import base58
import httpx
from ecdsa import SECP256k1
from ecdsa.ellipticcurve import Point
from mnemonic import Mnemonic
from eth_account import Account
from eth_utils import to_checksum_address

# Real bug this closes: a manually mouse-selected address copied out of a
# PowerShell/terminal window can silently pick up a stray line-wrap or
# trailing space depending on window width, producing an address that
# LOOKS right when read but gets rejected by MetaMask as invalid. Copying
# the exact string straight to the clipboard sidesteps manual selection
# entirely. Never crashes the script if it's unavailable (e.g. no display
# server) — it's a convenience, not a requirement.
try:
    import pyperclip
    def _copy_to_clipboard(text: str) -> bool:
        pyperclip.copy(text)
        return True
except Exception:
    def _copy_to_clipboard(text: str) -> bool:
        return False

DEFAULT_GATEWAY_API_URL = "https://api.zynost.com/api"

_CURVE = SECP256k1
_ORDER = _CURVE.order
_GENERATOR = _CURVE.generator
_XPUB_VERSION = bytes.fromhex("0488B21E")
_HARDENED_OFFSET = 0x80000000

# Real bug this closes: publicnode's free tier started rejecting
# eth_getTransactionReceipt for some transactions with "Archive requests
# require a personal token" — a rate-limit/tier restriction on their end,
# not anything wrong with the transaction. A single-provider dependency
# means any one flaky/rate-limited public RPC can stall a sweep entirely.
# Each chain now has a fallback list — _rpc_call tries them in order and
# only fails if every one of them does.
_MAINNET_RPCS = {
    "ethereum": ["https://ethereum-rpc.publicnode.com", "https://eth.llamarpc.com", "https://rpc.ankr.com/eth"],
    "bsc": ["https://bsc-dataseed.binance.org", "https://bsc-rpc.publicnode.com", "https://bsc-dataseed1.defibit.io", "https://rpc.ankr.com/bsc"],
    "polygon": ["https://polygon-rpc.com", "https://polygon-bor-rpc.publicnode.com", "https://rpc.ankr.com/polygon"],
}
_CHAIN_IDS = {"ethereum": 1, "bsc": 56, "polygon": 137}
_STABLECOIN_CONTRACTS = {
    "ethereum": {"0xdac17f958d2ee523a2206206994597c13d831ec7": "USDT", "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": "USDC"},
    "bsc": {"0x55d398326f99059ff775485246999027b3197955": "USDT", "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d": "USDC"},
    "polygon": {"0xc2132d05d31c914a87c6611c10748aeb04b58e8f": "USDT", "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359": "USDC"},
}
_MIN_ERC20_GAS = 65_000
_MIN_NATIVE_TRANSFER_GAS = 21_000  # standard cost of a plain value transfer, no contract call

# Fixed, permanently-reserved derivation index for a "gas station" address -
# never assigned to a real order (Zynost Pay's real per-order counter starts
# at 0 and only ever increments by 1 per checkout). Fund THIS ONE address
# once per chain you use, and the script auto-tops-up whichever order
# address needs a small amount of gas before sweeping it, instead of a
# separate manual top-up for every single order's own unique address.
GAS_RESERVE_EVM_INDEX = 999_999_999


# --- Minimal BIP32 (secp256k1), inlined so this file has no dependency on
# Zynost's own codebase — see app/services/bip32_lite.py in the gateway
# backend for the original, fuller version this is drawn from. ---

def _hmac_sha512(key: bytes, data: bytes) -> bytes:
    return hmac.new(key, data, hashlib.sha512).digest()


def _hash160(data: bytes) -> bytes:
    return hashlib.new("ripemd160", hashlib.sha256(data).digest()).digest()


def _compress_point(point: Point) -> bytes:
    prefix = b"\x03" if point.y() % 2 else b"\x02"
    return prefix + point.x().to_bytes(32, "big")


def _privkey_to_pubkey_compressed(privkey_int: int) -> bytes:
    return _compress_point(_GENERATOR * privkey_int)


def _b58check_encode(payload: bytes) -> str:
    checksum = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4]
    return base58.b58encode(payload + checksum).decode()


def _master_key_from_seed(seed: bytes) -> tuple[int, bytes]:
    digest = _hmac_sha512(b"Bitcoin seed", seed)
    return int.from_bytes(digest[:32], "big"), digest[32:]


def _ckd_priv(parent_privkey: int, parent_chaincode: bytes, index: int) -> tuple[int, bytes]:
    if index >= _HARDENED_OFFSET:
        data = b"\x00" + parent_privkey.to_bytes(32, "big") + index.to_bytes(4, "big")
    else:
        data = _privkey_to_pubkey_compressed(parent_privkey) + index.to_bytes(4, "big")
    digest = _hmac_sha512(parent_chaincode, data)
    il, child_chaincode = digest[:32], digest[32:]
    child_privkey = (int.from_bytes(il, "big") + parent_privkey) % _ORDER
    return child_privkey, child_chaincode


def _derive_priv_path(seed: bytes, path: str) -> tuple[int, bytes, bytes, int, int]:
    privkey, chaincode = _master_key_from_seed(seed)
    parent_pubkey = _privkey_to_pubkey_compressed(privkey)
    depth = 0
    child_number = 0
    for segment in path.strip().split("/"):
        if segment == "m" or segment == "":
            continue
        hardened = segment.endswith("'")
        index = int(segment[:-1] if hardened else segment)
        if hardened:
            index += _HARDENED_OFFSET
        parent_pubkey = _privkey_to_pubkey_compressed(privkey)
        privkey, chaincode = _ckd_priv(privkey, chaincode, index)
        depth += 1
        child_number = index
    return privkey, chaincode, parent_pubkey, depth, child_number


def _serialize_xpub(pubkey_compressed: bytes, chaincode: bytes, parent_pubkey_compressed: bytes, depth: int, child_number: int) -> str:
    parent_fingerprint = _hash160(parent_pubkey_compressed)[:4] if depth > 0 else b"\x00\x00\x00\x00"
    payload = (
        _XPUB_VERSION + depth.to_bytes(1, "big") + parent_fingerprint
        + child_number.to_bytes(4, "big") + chaincode + pubkey_compressed
    )
    return _b58check_encode(payload)


def _derive_evm_private_key(seed: bytes, index: int) -> str:
    privkey, chaincode, _, _, _ = _derive_priv_path(seed, "m/44'/60'/0'")
    change_privkey, change_chaincode = _ckd_priv(privkey, chaincode, 0)
    address_privkey, _ = _ckd_priv(change_privkey, change_chaincode, index)
    return "0x" + address_privkey.to_bytes(32, "big").hex()


# --- Chain RPC helpers ---

async def _rpc_call(client: httpx.AsyncClient, rpc_url, method: str, params: list):
    """rpc_url is a list of fallback endpoints for the chain (see
    _MAINNET_RPCS) — tried in order, only raising once every one of them
    has failed. Still accepts a plain string too, so nothing else calling
    this needs to change."""
    urls = rpc_url if isinstance(rpc_url, list) else [rpc_url]
    last_error: Exception | None = None
    for url in urls:
        try:
            resp = await client.post(url, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
            payload = resp.json()
            if "error" in payload:
                last_error = RuntimeError(f"{method} failed via {url}: {payload['error']}")
                continue
            return payload["result"]
        except httpx.HTTPError as e:
            last_error = e
            continue
    raise last_error or RuntimeError(f"{method} failed: no RPC endpoints configured")


def _reserve_evm_account(seed: bytes) -> "Account":
    return Account.from_key(_derive_evm_private_key(seed, GAS_RESERVE_EVM_INDEX))


async def _send_native_topup(
    client: httpx.AsyncClient, chain: str, rpc_url: str, reserve_account, to_address: str, amount_wei: int,
) -> str:
    """A plain native-token value transfer from the reserve account -
    separate from the ERC20 transfer builder below, which always sends a
    token, never the chain's own native currency."""
    gas_price = int(await _rpc_call(client, rpc_url, "eth_gasPrice", []), 16)
    nonce = int(await _rpc_call(client, rpc_url, "eth_getTransactionCount", [reserve_account.address, "pending"]), 16)
    tx = {
        "nonce": nonce, "gasPrice": gas_price, "gas": _MIN_NATIVE_TRANSFER_GAS,
        "to": to_checksum_address(to_address), "value": amount_wei, "chainId": _CHAIN_IDS[chain],
    }
    signed = reserve_account.sign_transaction(tx)
    return await _rpc_call(client, rpc_url, "eth_sendRawTransaction", ["0x" + signed.raw_transaction.hex()])


async def _wait_for_native_balance(
    client: httpx.AsyncClient, rpc_url: str, address: str, at_least: int, attempts: int = 10, delay_seconds: float = 3.0,
) -> int:
    for _ in range(attempts):
        balance = int(await _rpc_call(client, rpc_url, "eth_getBalance", [address, "latest"]), 16)
        if balance >= at_least:
            return balance
        await asyncio.sleep(delay_seconds)
    return balance


async def _wait_for_receipt(
    client: httpx.AsyncClient, rpc_url: str, tx_hash: str, attempts: int = 15, delay_seconds: float = 3.0,
) -> dict | None:
    """A broadcast tx_hash only means the network ACCEPTED the transaction
    for inclusion - it says nothing about whether it actually succeeded.
    Real bug this fixes: this script used to report every broadcast as
    "SWEPT" the instant eth_sendRawTransaction returned a hash, even if the
    transaction later mined with status 0x0 (reverted) - a real, silent
    failure mode caught live when a blank/invalid destination address
    produced malformed calldata that reverted on-chain while the script
    still printed success. Returns None if it never confirms within the
    polling window (network congestion, not necessarily failure - re-run
    to check again, already-swept balances just read as zero)."""
    for _ in range(attempts):
        receipt = await _rpc_call(client, rpc_url, "eth_getTransactionReceipt", [tx_hash])
        if receipt is not None:
            return receipt
        await asyncio.sleep(delay_seconds)
    return None


def _is_valid_evm_address(address: str) -> bool:
    if not address.startswith("0x") or len(address) != 42:
        return False
    try:
        int(address[2:], 16)
        return True
    except ValueError:
        return False


async def _sweep_order(
    client: httpx.AsyncClient, order: dict, seed: bytes, destination: str, reserve_account=None,
) -> tuple[bool, str]:
    chain = order["paid_chain"]
    if chain not in _MAINNET_RPCS:
        return False, f"Unrecognized chain '{chain}' — skipping."

    contract_address = next(addr for addr, asset in _STABLECOIN_CONTRACTS[chain].items() if asset == order["paid_asset"])
    rpc_url = _MAINNET_RPCS[chain]
    private_key = _derive_evm_private_key(seed, order["evm_derivation_index"])
    account = Account.from_key(private_key)

    if account.address.lower() != order["evm_address"].lower():
        return False, f"DERIVATION MISMATCH — expected {order['evm_address']}, got {account.address}. Not sweeping."

    # Real bug this fixes: this used to check/top-up GAS before ever
    # checking whether there was a nonzero token balance worth spending
    # that gas on — so an address that was never funded with any
    # stablecoin at all (routine for expired/never-paid test orders)
    # produced a confusing "needs gas" error instead of the correct
    # "nothing here, skip it." Checking the (free, read-only) token
    # balance first means gas only ever gets topped up when there's
    # actually something to sweep.
    balance_call_data = "0x70a08231" + "000000000000000000000000" + account.address[2:].lower()
    token_balance = int(await _rpc_call(client, rpc_url, "eth_call", [{"to": contract_address, "data": balance_call_data}, "latest"]), 16)
    if token_balance == 0:
        return False, "Balance is already zero — nothing to sweep here."

    native_balance = int(await _rpc_call(client, rpc_url, "eth_getBalance", [account.address, "latest"]), 16)
    gas_price = int(await _rpc_call(client, rpc_url, "eth_gasPrice", []), 16)
    required_gas = _MIN_ERC20_GAS * gas_price

    if native_balance < required_gas:
        shortfall = required_gas - native_balance
        if reserve_account is None:
            return False, f"Needs gas: send at least {shortfall / 1e18:.6f} native token to {account.address} on {chain} first."
        topup_amount = int(shortfall * 1.2)  # headroom for a gas-price tick between here and the sweep tx below
        reserve_balance = int(await _rpc_call(client, rpc_url, "eth_getBalance", [reserve_account.address, "latest"]), 16)
        if reserve_balance < topup_amount + _MIN_NATIVE_TRANSFER_GAS * gas_price:
            return False, (
                f"Needs gas, and the reserve address ({reserve_account.address}) doesn't have enough "
                f"on {chain} to auto-top-up either - fund the reserve address on {chain} first, then re-run."
            )
        await _send_native_topup(client, chain, rpc_url, reserve_account, account.address, topup_amount)
        native_balance = await _wait_for_native_balance(client, rpc_url, account.address, required_gas)
        if native_balance < required_gas:
            return False, (
                f"Sent a gas top-up from the reserve to {account.address} on {chain}, but it hasn't "
                f"landed yet - re-run in a minute to sweep this one."
            )

    transfer_data = "0xa9059cbb" + "000000000000000000000000" + destination[2:].lower() + hex(token_balance)[2:].zfill(64)
    nonce = int(await _rpc_call(client, rpc_url, "eth_getTransactionCount", [account.address, "pending"]), 16)
    tx = {
        "nonce": nonce, "gasPrice": gas_price, "gas": _MIN_ERC20_GAS,
        "to": to_checksum_address(contract_address), "value": 0, "data": transfer_data,
        "chainId": _CHAIN_IDS[chain],
    }
    signed = account.sign_transaction(tx)
    tx_hash = await _rpc_call(client, rpc_url, "eth_sendRawTransaction", ["0x" + signed.raw_transaction.hex()])

    receipt = await _wait_for_receipt(client, rpc_url, tx_hash)
    if receipt is None:
        return False, f"Broadcast as {tx_hash} but didn't confirm in time - re-run to check its real result."
    if receipt.get("status") != "0x1":
        return False, f"Transaction {tx_hash} MINED BUT REVERTED (status 0x0) - funds were NOT moved. Re-run to retry."
    return True, tx_hash


async def _sweep_expired_order(
    client: httpx.AsyncClient, order: dict, seed: bytes, destination: str, reserve_account=None,
) -> tuple[bool, str]:
    """An EXPIRED order (never reached "paid") has no paid_chain/paid_asset
    on file — the customer either sent too little (real case this closes:
    an underpaid test/real checkout whose funds are otherwise permanently
    stuck, since the normal /v1/checkout flow will never mark it paid and
    the regular paid-only sweep never looks at it) or never paid at all.
    Since this address is unambiguously derived from the merchant's own
    xpub regardless of what the order record says, it's safe to check
    every chain/stablecoin combination directly and sweep whatever is
    actually sitting there. A still-PENDING (not yet expired) order is
    deliberately never touched this way — a customer might still be
    mid-payment."""
    last_detail = "No balance found on any supported chain."
    for chain, tokens in _STABLECOIN_CONTRACTS.items():
        for _, asset in tokens.items():
            probe_order = {**order, "paid_chain": chain, "paid_asset": asset}
            try:
                ok, detail = await _sweep_order(client, probe_order, seed, destination, reserve_account=reserve_account)
            except Exception as e:
                last_detail = f"{chain}/{asset}: {type(e).__name__}: {e}"
                continue
            if ok:
                return True, f"{detail} (found on {chain}, {asset})"
            if "already zero" not in detail and "already swept" not in detail:
                last_detail = f"{chain}/{asset}: {detail}"
    return False, last_detail


async def _withdraw_reserve_balance(client: httpx.AsyncClient, reserve_account, destination: str) -> None:
    """A SEPARATE, deliberate action from the normal sweep above — pulls
    whatever native token (BNB/ETH/MATIC) is sitting unused in the
    gas-reserve address itself back out to the merchant's own wallet.
    Checks every chain the reserve address might have been funded on;
    only withdraws where there's real balance above dust/gas-cost."""
    for chain, rpc_url in _MAINNET_RPCS.items():
        try:
            balance = int(await _rpc_call(client, rpc_url, "eth_getBalance", [reserve_account.address, "latest"]), 16)
            gas_price = int(await _rpc_call(client, rpc_url, "eth_gasPrice", []), 16)
        except Exception as e:
            print(f"[{chain}] Couldn't check balance ({type(e).__name__}: {e}) — skipping.")
            continue
        gas_cost = _MIN_NATIVE_TRANSFER_GAS * gas_price
        if balance <= gas_cost:
            print(f"[{chain}] Nothing worth withdrawing ({balance / 1e18:.8f} native token, that's less than the gas cost to move it).")
            continue
        amount_to_send = balance - gas_cost
        try:
            tx_hash = await _send_native_topup(client, chain, rpc_url, reserve_account, destination, amount_to_send)
            receipt = await _wait_for_receipt(client, rpc_url, tx_hash)
            if receipt is None:
                print(f"[{chain}] Broadcast as {tx_hash} but didn't confirm in time — re-run to check its real result.")
            elif receipt.get("status") != "0x1":
                print(f"[{chain}] Transaction {tx_hash} MINED BUT REVERTED — funds were NOT moved.")
            else:
                print(f"[{chain}] Withdrew {amount_to_send / 1e18:.8f} native token to {destination} — tx {tx_hash}")
        except Exception as e:
            print(f"[{chain}] Withdrawal failed ({type(e).__name__}: {e}).")


_AUTO_SWEEP_INTERVAL_SECONDS = 4 * 60 * 60  # every 4 hours


async def _run_sweep_pass(api_url: str, headers: dict, seed: bytes, destination: str, reserve_account=None) -> None:
    async with httpx.AsyncClient(timeout=20) as client:
        paid_resp = await client.get(f"{api_url}/v1/orders", headers=headers, params={"status": "paid"})
        paid_orders = paid_resp.json()["orders"]
        # Real case this closes: an underpaid or otherwise never-completed
        # checkout stays "pending" until its 45-minute window passes, then
        # flips to "expired" - it never becomes "paid", so it was
        # invisible to this tool entirely even though the address (and
        # whatever landed at it) is still unambiguously the merchant's
        # own. Still-PENDING orders are deliberately left alone below - a
        # customer might genuinely be mid-payment.
        expired_resp = await client.get(f"{api_url}/v1/orders", headers=headers, params={"status": "expired"})
        expired_orders = expired_resp.json()["orders"]

    print(f"Found {len(paid_orders)} paid order(s), {len(expired_orders)} expired order(s) to check.")
    if not paid_orders and not expired_orders:
        print("Nothing to sweep.")
        return

    async with httpx.AsyncClient(timeout=20) as client:
        for order in paid_orders:
            if not order.get("paid_chain"):
                continue
            # The invoiced amount is shown for identification only - the
            # actual amount swept is whatever balance is really sitting at
            # this address right now, which can be more than what this one
            # order billed for (e.g. leftover from a reused gasless smart
            # wallet) - _sweep_order always moves the real on-chain balance,
            # never just the invoiced figure.
            label = f"order {order['id']} ({order['paid_chain']}, {order['paid_asset']}, invoiced ${order['amount_usd']})"
            try:
                ok, detail = await _sweep_order(client, order, seed, destination, reserve_account=reserve_account)
            except Exception as e:
                import traceback
                traceback.print_exc()
                ok, detail = False, f"Unexpected error ({type(e).__name__}): {e}"
            print(f"[{'SWEPT (full balance)' if ok else 'FAILED'}] {label}: {detail}")

        for order in expired_orders:
            label = f"expired order {order['id']} (invoiced ${order['amount_usd']}, address {order['evm_address']})"
            try:
                ok, detail = await _sweep_expired_order(client, order, seed, destination, reserve_account=reserve_account)
            except Exception as e:
                import traceback
                traceback.print_exc()
                ok, detail = False, f"Unexpected error ({type(e).__name__}): {e}"
            print(f"[{'RECOVERED' if ok else 'skip'}] {label}: {detail}")


async def main():
    print("=" * 78)
    print("ZYNOST PAY — MERCHANT FUND SWEEP")
    print("=" * 78)

    # SWEEP_API_URL / SWEEP_API_KEY let you skip the interactive prompts
    # below entirely (e.g. `$env:SWEEP_API_KEY = (Get-Content key.txt -Raw
    # ).Trim()` in PowerShell) - some terminals mangle a pasted value
    # partway through a chain of input() calls (a real report: the API URL
    # prompt ended up empty/garbled after pasting into the key prompt),
    # and reading the key from a file/env var sidesteps that entirely.
    api_url = os.environ.get("SWEEP_API_URL", "").strip()
    if not api_url:
        api_url = input(f"\nGateway API URL [{DEFAULT_GATEWAY_API_URL}]: ").strip() or DEFAULT_GATEWAY_API_URL
    api_key = os.environ.get("SWEEP_API_KEY", "").strip()
    if not api_key:
        api_key = input("Your Zynost Pay API key (zg_live_...): ").strip()
    headers = {"Authorization": f"Bearer {api_key}"}

    async with httpx.AsyncClient(timeout=20) as client:
        info_resp = await client.get(f"{api_url}/v1/merchant/info", headers=headers)
        if info_resp.status_code != 200:
            print(f"Could not authenticate with that API key ({info_resp.status_code}): {info_resp.text}")
            return
        merchant_info = info_resp.json()

    print(f"Authenticated as: {merchant_info['business_name']}\n")
    print(
        "Mnemonic will be typed VISIBLY below (hidden input proved unreliable "
        "across terminals) — make sure no one else can see this screen. It is\n"
        "kept in memory for this run only — never written to disk, never sent\n"
        "anywhere but the public chain RPCs this script calls directly.\n"
    )
    mnemonic_words = input("Enter your 24-word mnemonic: ").strip()
    seed = Mnemonic("english").to_seed(mnemonic_words)

    privkey, chaincode, parent_pubkey, depth, child_number = _derive_priv_path(seed, "m/44'/60'/0'")
    account_pubkey = _privkey_to_pubkey_compressed(privkey)
    derived_xpub = _serialize_xpub(account_pubkey, chaincode, parent_pubkey, depth, child_number)
    if derived_xpub != merchant_info["payout_evm_xpub"]:
        print("\n" + "!" * 78)
        print("MNEMONIC DOES NOT MATCH YOUR ON-FILE XPUB — stopping before touching anything.")
        print(f"  Your on-file xpub: {merchant_info['payout_evm_xpub']}")
        print(f"  This mnemonic derives to: {derived_xpub}")
        print("!" * 78)
        return
    print("Mnemonic verified — matches your on-file xpub.\n")

    # Same address every run (fixed derivation index, never assigned to a
    # real order) - fund THIS ONE address once per chain you use instead of
    # a brand new address needing its own manual gas top-up for every
    # single order.
    reserve_account = _reserve_evm_account(seed)
    print(f"Gas-reserve address (fund this ONCE per chain you use): {reserve_account.address}")
    if _copy_to_clipboard(reserve_account.address):
        print("(Copied to clipboard — just press Ctrl+V wherever you're sending from, don't retype/re-select it.)\n")
    else:
        print()

    action = input(
        "\nWhat do you want to do?\n"
        "  [1] Sweep customer payments to your wallet (the usual case)\n"
        "  [2] Withdraw leftover gas from the reserve address itself\n"
        "Choice [1]: "
    ).strip() or "1"

    # Real bug this closes: an empty/malformed destination used to sail
    # straight through into a broadcast transaction with corrupted calldata
    # that reverted on-chain, wasting real gas and moving nothing - now
    # rejected before ever touching the chain. SWEEP_DESTINATION mirrors
    # SWEEP_API_URL/SWEEP_API_KEY above but is still validated even when
    # set that way.
    destination = os.environ.get("SWEEP_DESTINATION", "").strip()
    while not _is_valid_evm_address(destination):
        if destination:
            print(f"'{destination}' doesn't look like a valid 0x address (need '0x' + 40 hex characters) - try again.")
        destination = input("Destination EVM address (your real wallet/exchange, 0x...): ").strip()

    if action == "2":
        print()
        async with httpx.AsyncClient(timeout=20) as client:
            await _withdraw_reserve_balance(client, reserve_account, destination)
        return

    mode = input(
        "\nRun once, or keep running and auto-sweep every 4 hours? [once/auto] (once): "
    ).strip().lower() or "once"

    if mode != "auto":
        print()
        await _run_sweep_pass(api_url, headers, seed, destination, reserve_account=reserve_account)
        return

    print(
        f"\nAuto-sweep mode: checking every 4 hours. Leave this running "
        f"(e.g. in a systemd service, a `screen`/`tmux` session, or `nohup ... &`) "
        f"— press Ctrl+C to stop.\n"
    )
    while True:
        print(f"--- Sweep pass at {datetime.now(timezone.utc).isoformat()} ---")
        try:
            await _run_sweep_pass(api_url, headers, seed, destination, reserve_account=reserve_account)
        except Exception as e:
            import traceback
            traceback.print_exc()
            print(f"Pass failed ({type(e).__name__}: {e}) — will retry next cycle.")
        print(f"Sleeping {_AUTO_SWEEP_INTERVAL_SECONDS // 3600}h until next pass...\n")
        await asyncio.sleep(_AUTO_SWEEP_INTERVAL_SECONDS)


if __name__ == "__main__":
    asyncio.run(main())
