diff --git a/basicswap/base.py b/basicswap/base.py index 0fb2421..6181e12 100644 --- a/basicswap/base.py +++ b/basicswap/base.py @@ -5,10 +5,12 @@ # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. +import json import logging import os import random import shlex +import shutil import socket import socks import subprocess @@ -55,7 +57,7 @@ class BaseApp(DBMethods): self.settings = settings self.coin_clients = {} self.coin_interfaces = {} - self.mxDB = threading.Lock() + self.mxDB = threading.RLock() self.debug = self.settings.get("debug", False) self.delay_event = threading.Event() self.chainstate_delay_event = threading.Event() @@ -156,6 +158,71 @@ class BaseApp(DBMethods): except Exception: return {} + def getElectrumAddressIndex(self, coin_name: str) -> tuple: + try: + chain_settings = self.settings["chainclients"].get(coin_name, {}) + ext_idx = chain_settings.get("electrum_address_index", 0) + int_idx = chain_settings.get("electrum_internal_address_index", 0) + return (ext_idx, int_idx) + except Exception: + return (0, 0) + + def updateElectrumAddressIndex( + self, coin_name: str, ext_idx: int, int_idx: int + ) -> None: + try: + if coin_name not in self.settings["chainclients"]: + self.log.debug( + f"updateElectrumAddressIndex: {coin_name} not in chainclients" + ) + return + + chain_settings = self.settings["chainclients"][coin_name] + current_ext = chain_settings.get("electrum_address_index", 0) + current_int = chain_settings.get("electrum_internal_address_index", 0) + + if ext_idx <= current_ext and int_idx <= current_int: + return + + if ext_idx > current_ext: + chain_settings["electrum_address_index"] = ext_idx + if int_idx > current_int: + chain_settings["electrum_internal_address_index"] = int_idx + + self.log.debug( + f"Persisting electrum address index for {coin_name}: ext={ext_idx}, int={int_idx}" + ) + self._saveSettings() + except Exception as e: + self.log.warning( + f"Failed to update electrum address index for {coin_name}: {e}" + ) + + def _normalizeSettingsPaths(self, settings: dict) -> dict: + if "chainclients" in settings: + for coin_name, cc in settings["chainclients"].items(): + for path_key in ("datadir", "bindir", "walletsdir"): + if path_key in cc and isinstance(cc[path_key], str): + cc[path_key] = os.path.normpath(cc[path_key]) + return settings + + def _saveSettings(self) -> None: + from basicswap import config as cfg + + self._normalizeSettingsPaths(self.settings) + + settings_path = os.path.join(self.data_dir, cfg.CONFIG_FILENAME) + settings_path_new = settings_path + ".new" + try: + if os.path.exists(settings_path): + shutil.copyfile(settings_path, settings_path + ".last") + with open(settings_path_new, "w") as fp: + json.dump(self.settings, fp, indent=4) + shutil.move(settings_path_new, settings_path) + self.log.debug(f"Settings saved to {settings_path}") + except Exception as e: + self.log.warning(f"Failed to save settings: {e}") + def setDaemonPID(self, name, pid) -> None: if isinstance(name, Coins): self.coin_clients[name]["pid"] = pid diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index 26e7d45..299b222 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -147,6 +147,15 @@ from .db import ( XmrSplitData, XmrSwap, ) +from .wallet_manager import WalletManager +from .db_wallet import ( + WalletAddress, + WalletLockedUTXO, + WalletPendingTx, + WalletState, + WalletTxCache, + WalletWatchOnly, +) from .explorers import ( ExplorerInsight, ExplorerBitAps, @@ -194,7 +203,10 @@ def checkAndNotifyBalanceChange( verification_progress = blockchain_info.get("verificationprogress", 1.0) if verification_progress < 0.99: return - except Exception: + except Exception as e: + swap_client.log.debug( + f"checkAndNotifyBalanceChange {ci.ticker()}: getBlockchainInfo failed: {e}" + ) return try: @@ -217,6 +229,9 @@ def checkAndNotifyBalanceChange( cc["cached_balance"] = current_balance cc["cached_total_balance"] = current_total_balance cc["cached_unconfirmed"] = current_unconfirmed + swap_client.log.debug( + f"{ci.ticker()} balance updated (trigger: {trigger_source})" + ) balance_event = { "event": "coin_balance_updated", "coin": ci.ticker(), @@ -224,7 +239,10 @@ def checkAndNotifyBalanceChange( "trigger": trigger_source, } swap_client.ws_server.send_message_to_all(json.dumps(balance_event)) - except Exception: + except Exception as e: + swap_client.log.debug( + f"checkAndNotifyBalanceChange {ci.ticker()}: balance check failed: {e}" + ) cc["cached_balance"] = None cc["cached_total_balance"] = None cc["cached_unconfirmed"] = None @@ -288,6 +306,92 @@ def threadPollChainState(swap_client, coin_type): swap_client.chainstate_delay_event.wait(random.randrange(*poll_delay_range)) +def threadPollElectrumChainState(swap_client, coin_type): + ci = swap_client.ci(coin_type) + cc = swap_client.coin_clients[coin_type] + + poll_interval = cc.get("electrum_poll_interval", 10) + poll_delay_range = (poll_interval, poll_interval + 5) + consecutive_errors = 0 + backoff_seconds = 30 + max_backoff_seconds = 300 + + swap_client.log.info(f"Electrum polling thread started for {ci.ticker()}") + + try: + ci._electrum_scan_counter = 1 + if hasattr(ci, "_queryElectrumWalletInfo"): + ci._queryElectrumWalletInfo(funded_only=True) + swap_client.log.debug( + f"Primed {ci.ticker()} wallet cache with funded addresses" + ) + except Exception as e: + swap_client.log.debug(f"Failed to prime wallet cache for {ci.ticker()}: {e}") + + while not swap_client.chainstate_delay_event.is_set(): + try: + chain_state = ci.getBlockchainInfo() + new_height: int = chain_state["blocks"] + + if consecutive_errors > 0: + swap_client.log.info( + f"threadPollElectrumChainState {ci.ticker()}: connection recovered" + ) + consecutive_errors = 0 + backoff_seconds = 30 + + block_changed = new_height != cc.get("chain_height", 0) + if block_changed: + swap_client.log.debug( + f"New {ci.ticker()} electrum block at height: {new_height}" + ) + with swap_client.mxDB: + cc["chain_height"] = new_height + if "bestblockhash" in chain_state: + cc["chain_best_block"] = chain_state["bestblockhash"] + + try: + ci.refreshElectrumWalletInfo() + checkAndNotifyBalanceChange( + swap_client, coin_type, ci, cc, new_height, "electrum_poll" + ) + except Exception as refresh_err: + swap_client.log.debug( + f"threadPollElectrumChainState {ci.ticker()} refresh error: {refresh_err}" + ) + + except Exception as e: + consecutive_errors += 1 + swap_client.log.warning( + f"threadPollElectrumChainState {ci.ticker()}, error ({consecutive_errors}): {e}" + ) + + if consecutive_errors >= 3: + try: + backend = ci.getBackend() + if backend and hasattr(backend, "disconnect"): + backend.disconnect() + swap_client.log.debug( + f"threadPollElectrumChainState {ci.ticker()}: disconnected to trigger server rotation" + ) + except Exception: + pass + + jitter = random.randint(0, 10) + wait_time = min(backoff_seconds + jitter, max_backoff_seconds) + swap_client.log.warning( + f"threadPollElectrumChainState {ci.ticker()}: backing off {wait_time}s after {consecutive_errors} errors" + ) + swap_client.chainstate_delay_event.wait(wait_time) + + backoff_seconds = min(backoff_seconds * 2, max_backoff_seconds) + continue + + swap_client.chainstate_delay_event.wait(random.randrange(*poll_delay_range)) + + swap_client.log.info(f"Electrum polling thread exiting for {ci.ticker()}") + + class BasicSwap(BaseApp, BSXNetwork, UIApp): ws_server = None protocolInterfaces = { @@ -344,6 +448,8 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self._last_checked_watched = 0 self._last_checked_split_messages = 0 self._last_checked_delayed_auto_accept = 0 + self._last_checked_pending_sweeps = 0 + self._pending_sweeps = {} self._possibly_revoked_offers = collections.deque( [], maxlen=48 ) # TODO: improve @@ -351,6 +457,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self._expiring_offers = [] # List of offers expiring soon self._updating_wallets_info = {} self._last_updated_wallets_info = 0 + self._synced_addresses_from_full_node = set() self.check_updates_seconds = self.get_int_setting( "check_updates_seconds", 24 * 60 * 60, 60 * 60, 7 * 24 * 60 * 60 @@ -385,7 +492,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self._notifications_cache = {} self._is_encrypted = None self._is_locked = None - + self._tx_cache = {} self._price_cache = {} self._volume_cache = {} self._historical_cache = {} @@ -459,7 +566,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): "max_sequence_lock_seconds", 96 * 60 * 60 ) - self._wallet_update_timeout = self.settings.get("wallet_update_timeout", 10) + self._wallet_update_timeout = self.settings.get("wallet_update_timeout", 30) self._restrict_unknown_seed_wallets = self.settings.get( "restrict_unknown_seed_wallets", True @@ -493,7 +600,15 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): if not db_exists: self.log.info("First run") - create_db(self.sqlite_file, self.log) + wallet_tables = [ + WalletAddress, + WalletLockedUTXO, + WalletPendingTx, + WalletState, + WalletTxCache, + WalletWatchOnly, + ] + create_db(self.sqlite_file, self.log, extra_tables=wallet_tables) cursor = self.openDB() try: @@ -545,6 +660,8 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): random.seed(secrets.randbits(128)) + self._wallet_manager = WalletManager(self, self.log) + self._prepare_rpc_pooling() def _prepare_rpc_pooling(self): @@ -571,7 +688,8 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): settings_path = os.path.join(self.data_dir, cfg.CONFIG_FILENAME) settings_path_new = settings_path + ".new" try: - shutil.copyfile(settings_path, settings_path + ".last") + if os.path.exists(settings_path): + shutil.copyfile(settings_path, settings_path + ".last") with open(settings_path_new, "w") as fp: json.dump(self.settings, fp, indent=4) shutil.move(settings_path_new, settings_path) @@ -747,6 +865,11 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): "wallet_name", "watch_wallet_name", "mweb_wallet_name", + "electrum_clearnet_servers", + "electrum_onion_servers", + "electrum_host", + "electrum_port", + "electrum_ssl", ): if setting_name in chain_client_settings: self.coin_clients[coin][setting_name] = chain_client_settings[ @@ -956,6 +1079,38 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): raise ValueError(f"Unknown protocol_ind {protocol_ind}") return self.protocolInterfaces[protocol_ind] + def _initElectrumBackend(self, coin, interface) -> bool: + from .wallet_backend import ElectrumBackend + + try: + proxy_host = self.tor_proxy_host if self.use_tor_proxy else None + proxy_port = self.tor_proxy_port if self.use_tor_proxy else None + + clearnet_servers = self.coin_clients[coin].get( + "electrum_clearnet_servers", None + ) + onion_servers = self.coin_clients[coin].get("electrum_onion_servers", None) + + backend = ElectrumBackend( + coin, + self.log, + clearnet_servers=clearnet_servers, + onion_servers=onion_servers, + chain=self.chain, + proxy_host=proxy_host, + proxy_port=proxy_port, + ) + interface.setBackend(backend) + ticker = interface.ticker() if hasattr(interface, "ticker") else str(coin) + self.log.info( + f"{ticker} using ElectrumBackend{' via TOR' if self.use_tor_proxy else ''}" + ) + return True + except Exception as e: + ticker = interface.ticker() if hasattr(interface, "ticker") else str(coin) + self.log.error(f"Could not initialize {ticker} electrum backend: {e}") + return False + def createInterface(self, coin): if coin == Coins.PART: interface = PARTInterface(self.coin_clients[coin], self.chain, self) @@ -969,7 +1124,13 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): elif coin == Coins.BTC: from .interface.btc import BTCInterface - return BTCInterface(self.coin_clients[coin], self.chain, self) + connection_type = self.coin_clients[coin].get("connection_type", "rpc") + interface = BTCInterface(self.coin_clients[coin], self.chain, self) + + if connection_type == "electrum": + self._initElectrumBackend(coin, interface) + + return interface elif coin == Coins.BCH: from .interface.bch import BCHInterface @@ -977,10 +1138,16 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): elif coin == Coins.LTC: from .interface.ltc import LTCInterface, LTCInterfaceMWEB + connection_type = self.coin_clients[coin].get("connection_type", "rpc") interface = LTCInterface(self.coin_clients[coin], self.chain, self) - self.coin_clients[coin]["interface_mweb"] = LTCInterfaceMWEB( - self.coin_clients[coin], self.chain, self - ) + + if connection_type == "electrum": + self._initElectrumBackend(coin, interface) + else: + self.coin_clients[coin]["interface_mweb"] = LTCInterfaceMWEB( + self.coin_clients[coin], self.chain, self + ) + return interface elif coin == Coins.DOGE: from .interface.doge import DOGEInterface @@ -1091,7 +1258,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): raise ValueError("Error, terminating") def createCoinInterface(self, coin): - if self.coin_clients[coin]["connection_type"] == "rpc": + if self.coin_clients[coin]["connection_type"] in ("rpc", "electrum"): self.coin_clients[coin]["interface"] = self.createInterface(coin) elif self.coin_clients[coin]["connection_type"] == "passthrough": self.coin_clients[coin]["interface"] = self.createPassthroughInterface(coin) @@ -1196,8 +1363,45 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self.checkWalletSeed(c) + if c in WalletManager.SUPPORTED_COINS: + self._syncLiteWalletToRPCOnStartup(c) + + elif self.coin_clients[c]["connection_type"] == "electrum": + ci = self.ci(c) + self.log.info(f"{ci.coin_name()} using Electrum light wallet mode") + + self._initializeElectrumWallets() + + for c in self.activeCoins(): + if self.coin_clients[c]["connection_type"] == "electrum": + self.checkWalletSeed(c) + + for c in self.activeCoins(): + if self.coin_clients[c]["connection_type"] == "electrum": + try: + ci = self.ci(c) + electrum_version = ci.getDaemonVersion() + self.coin_clients[c]["core_version"] = electrum_version + self.log.info(f"{ci.coin_name()} connected to {electrum_version}") + except Exception as e: + self.coin_clients[c]["core_version"] = "Electrum" + self.log.debug( + f"Could not get electrum server version for {c}: {e}" + ) self._enable_rpc_pooling() + for c in self.activeCoins(): + if self.coin_clients[c]["connection_type"] == "electrum": + ci = self.ci(c) + self.log.info(f"Starting electrum polling thread for {ci.coin_name()}") + t = threading.Thread( + target=threadPollElectrumChainState, + args=(self, c), + name=f"electrum_poll_{ci.ticker()}", + ) + self.threads.append(t) + t.start() + if "p2p_host" in self.settings: network_key = self.getNetworkKey(1) self._network = bsn.Network( @@ -1436,10 +1640,12 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): ci.coin_name() ) ) - if self.coin_clients[c]["connection_type"] != "rpc": + if self.coin_clients[c]["connection_type"] not in ("rpc", "electrum"): continue if c in (Coins.XMR, Coins.WOW): continue # TODO + if self.coin_clients[c]["connection_type"] == "electrum": + continue synced = round(ci.getBlockchainInfo()["verificationprogress"], 3) if synced < 1.0: raise ValueError( @@ -1522,7 +1728,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): def isBaseCoinActive(self, c) -> bool: if c not in chainparams: return False - if self.coin_clients[c]["connection_type"] == "rpc": + if self.coin_clients[c]["connection_type"] in ("rpc", "electrum"): return True return False @@ -1537,7 +1743,8 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): Coins.PART, ] + [c for c in self.activeCoins() if c != Coins.PART] if Coins.LTC in coins_list: - coins_list.append(Coins.LTC_MWEB) + if self.coin_clients[Coins.LTC].get("connection_type") != "electrum": + coins_list.append(Coins.LTC_MWEB) return coins_list def changeWalletPasswords( @@ -1592,9 +1799,1140 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self._is_locked = False self.loadFromDB() + + self._initializeElectrumWallets() + + for c in self.activeCoins(): + if self.coin_clients[c].get("connection_type") == "electrum": + self.checkWalletSeed(c) + + for c in self.activeCoins(): + if c in WalletManager.SUPPORTED_COINS: + if self.coin_clients[c].get("connection_type") == "rpc": + try: + self._syncLiteWalletToRPCOnStartup(c) + except Exception as e: + self.log.debug( + f"Lite wallet sweep failed for {getCoinName(c)}: {e}" + ) + finally: self._read_zmq_queue = True + def _initializeElectrumWallets(self) -> None: + try: + is_encrypted, is_locked = self.getLockedState() + if is_locked: + self.log.debug( + "PART wallet is locked, skipping electrum wallet initialization" + ) + return + except Exception as e: + self.log.warning(f"Could not check locked state: {e}") + return + + for c in WalletManager.SUPPORTED_COINS: + if c in self.getListOfWalletCoins(): + cc = self.coin_clients.get(c) + if cc and cc.get("connection_type") == "electrum": + self.initializeWalletManager(c) + + def _enableElectrumRealtimeNotifications(self) -> None: + for c in self.activeCoins(): + cc = self.coin_clients.get(c) + if not cc or cc.get("connection_type") != "electrum": + continue + + try: + ci = self.ci(c) + if not hasattr(ci, "_backend") or ci._backend is None: + continue + + backend = ci._backend + + def make_callback(coin_type, coin_interface): + def on_address_change(ct, address, scripthash, event_type): + self._handleElectrumNotification( + ct, address, scripthash, event_type + ) + + return on_address_change + + callback = make_callback(c, ci) + backend.enableRealtimeNotifications(callback) + + self._subscribeElectrumAddresses(c, ci, backend) + + self.log.info(f"Real-time notifications enabled for {ci.coin_name()}") + + except Exception as e: + self.log.debug( + f"Could not enable realtime notifications for {Coins(c).name}: {e}" + ) + + def _subscribeElectrumAddresses(self, coin_type, ci, backend) -> None: + try: + wm = self._wallet_manager + if wm and wm.isInitialized(coin_type): + for i in range(20): + try: + addr = wm.getAddressAtIndex(coin_type, i, internal=False) + if addr: + backend.subscribeAddressWithCallback(addr) + except Exception: + break + + for i in range(10): + try: + addr = wm.getAddressAtIndex(coin_type, i, internal=True) + if addr: + backend.subscribeAddressWithCallback(addr) + except Exception: + break + + except Exception as e: + self.log.debug(f"Error subscribing electrum addresses: {e}") + + def _handleElectrumNotification( + self, coin_type, address, scripthash, event_type + ) -> None: + if not self.ws_server: + return + + try: + ci = self.ci(coin_type) + cc = self.coin_clients[coin_type] + + current_balance = ci.getSpendableBalance() + current_total = self.getTotalBalance(coin_type) + cached_balance = cc.get("cached_balance") + cached_total = cc.get("cached_total_balance") + + if current_balance != cached_balance or current_total != cached_total: + cc["cached_balance"] = current_balance + cc["cached_total_balance"] = current_total + cc["cached_unconfirmed"] = current_total - current_balance + + balance_event = { + "event": "coin_balance_updated", + "coin": ci.ticker(), + "height": cc.get("chain_height", 0), + "trigger": "electrum_notification", + "address": address[:20] + "..." if address else None, + } + self.ws_server.send_message_to_all(json.dumps(balance_event)) + self.log.debug(f"Electrum notification: {ci.ticker()} balance updated") + + except Exception as e: + self.log.debug(f"Error handling electrum notification: {e}") + + def _getCachedAddressesFromDB(self, coin_type: Coins) -> list: + addresses = [] + try: + ci = self.ci(coin_type) + coin_name = ci.coin_name().lower() + hrp = ci.chainparams_network().get("hrp", "") + + cursor = self.openDB() + try: + query = "SELECT key, value FROM kv_string WHERE key LIKE ?" + rows = cursor.execute(query, (f"%addr%{coin_name}%",)).fetchall() + for key, value in rows: + if value and isinstance(value, str): + if hrp and value.startswith(hrp): + addresses.append(value) + elif not hrp and value: + addresses.append(value) + + if hrp: + rows = cursor.execute( + "SELECT key, value FROM kv_string WHERE key LIKE 'receive_addr_%'" + ).fetchall() + for key, value in rows: + if value and isinstance(value, str) and value.startswith(hrp): + addresses.append(value) + finally: + self.closeDB(cursor, commit=False) + + addresses = list(set(addresses)) + if addresses: + self.log.info( + f"Found {len(addresses)} cached addresses for {getCoinName(coin_type)}" + ) + except Exception as e: + self.log.debug(f"_getCachedAddressesFromDB error: {e}") + return addresses + + def _migrateWalletToLiteMode(self, coin_type: Coins) -> dict: + result = {"success": False, "count": 0, "error": None} + + if coin_type not in WalletManager.SUPPORTED_COINS: + result["error"] = "Coin not supported for lite wallet" + return result + + try: + for bid_id, swap in self.swaps_in_progress.items(): + try: + bid, offer = swap + if offer.coin_from == coin_type or offer.coin_to == coin_type: + result["error"] = ( + "Cannot switch modes while swap is in progress" + ) + result["reason"] = "active_swap" + return result + except Exception: + pass + + if not self._wallet_manager.isInitialized(coin_type): + try: + root_key = self.getWalletKey(coin_type, 1) + self._wallet_manager.initialize(coin_type, root_key) + except Exception as e: + result["error"] = f"Failed to initialize WalletManager: {e}" + return result + + rpc_addresses = self._extractAllAddressesFromRPC(coin_type) + + keys_imported = 0 + try: + ci = self.ci(coin_type) + ci.checkWallets() + keys_imported = self._wallet_manager.importKeysFromRPC( + coin_type, lambda method, params: ci.rpc_wallet(method, params) + ) + if keys_imported > 0: + self.log.info( + f"Imported {keys_imported} private keys from RPC for {getCoinName(coin_type)}" + ) + except Exception as e: + self.log.warning(f"Could not import keys from RPC: {e}") + + added = self._wallet_manager.runMigration( + coin_type, + full_node_addresses=rpc_addresses if rpc_addresses else None, + num_addresses=20, + ) + + total_addresses = len(self._wallet_manager.getAllAddresses(coin_type)) + self.log.debug( + f"Migration: added {added} new, total {total_addresses} addresses for {getCoinName(coin_type)}" + ) + + self._clearCachedAddresses(coin_type) + + root_key = self.getWalletKey(coin_type, 1) + self.storeSeedIDForCoin(root_key, coin_type) + self.log.debug(f"Updated seed ID formats for {getCoinName(coin_type)}") + + result["success"] = True + result["count"] = total_addresses + result["keys_imported"] = keys_imported + self.log.info( + f"Lite wallet ready for {getCoinName(coin_type)} with {total_addresses} addresses, {keys_imported} keys imported" + ) + + except Exception as e: + result["error"] = str(e) + self.log.error(f"_migrateWalletToLiteMode error: {e}") + + return result + + def importWIFKey(self, coin_type: Coins, wif_key: str, label: str = "") -> dict: + result = {"success": False, "address": None, "error": None} + + if coin_type not in WalletManager.SUPPORTED_COINS: + result["error"] = "Coin not supported for lite wallet" + return result + + if not self._wallet_manager.isInitialized(coin_type): + result["error"] = "WalletManager not initialized for this coin" + return result + + try: + privkey = self._wallet_manager._decodeWIF(wif_key, coin_type) + if not privkey: + result["error"] = "Invalid WIF key format" + return result + + from coincurve import PrivateKey + + pk = PrivateKey(privkey) + pubkey = pk.public_key.format() + + ci = self.ci(coin_type) + pkh = ci.pkh(pubkey) + address = ci.pkh_to_address(pkh) + + if self._wallet_manager.importAddressWithKey( + coin_type, address, privkey, label=label, source="wif_import" + ): + result["success"] = True + result["address"] = address + self.log.info(f"Imported WIF key for address {address}") + else: + result["error"] = "Address already exists in wallet" + result["address"] = address + + except Exception as e: + result["error"] = str(e) + self.log.error(f"importWIFKey error: {e}") + + return result + + def _checkRPCWalletEmpty(self, coin_type: Coins) -> dict: + try: + for bid_id, swap in self.swaps_in_progress.items(): + try: + if hasattr(swap, "coin_from") and swap.coin_from == coin_type: + return { + "empty": False, + "reason": "active_swap", + "message": "Cannot switch: active swap in progress", + } + if hasattr(swap, "coin_to") and swap.coin_to == coin_type: + return { + "empty": False, + "reason": "active_swap", + "message": "Cannot switch: active swap in progress", + } + except Exception: + pass + + try: + cc = self.coin_clients[coin_type] + rpchost = cc.get("rpchost", "127.0.0.1") + rpcport = cc.get("rpcport") + if rpcport: + from basicswap.rpc import make_rpc_func + + rpcauth = cc.get("rpcauth") + wallet_name = cc.get("wallet_name", "wallet.dat") + rpc_func = make_rpc_func(rpcport, rpcauth, wallet_name, rpchost) + + balances = rpc_func("getbalances", []) + pending = balances.get("mine", {}).get("untrusted_pending", 0) + if pending != 0: + return { + "empty": False, + "reason": "pending_transactions", + "balance": pending, + "message": "Cannot switch: pending transactions in wallet. Wait for confirmations.", + } + except Exception: + pass + + return {"empty": True} + + except Exception as e: + self.log.error(f"_checkRPCWalletEmpty error: {e}") + return {"empty": True} + + def _checkElectrumWalletEmpty(self, coin_type: Coins) -> dict: + try: + for bid_id, swap in self.swaps_in_progress.items(): + try: + if hasattr(swap, "coin_from") and swap.coin_from == coin_type: + return { + "empty": False, + "reason": "active_swap", + "message": "Cannot switch: active swap in progress", + } + if hasattr(swap, "coin_to") and swap.coin_to == coin_type: + return { + "empty": False, + "reason": "active_swap", + "message": "Cannot switch: active swap in progress", + } + except Exception: + pass + + if coin_type in WalletManager.SUPPORTED_COINS: + try: + cursor = self.openDB() + try: + row = cursor.execute( + "SELECT SUM(cached_balance) as total FROM wallet_addresses " + "WHERE coin_type = ? AND cached_balance > 0", + (int(coin_type),), + ).fetchone() + if row and row[0] and row[0] > 0: + balance_sats = row[0] + balance_btc = balance_sats / 1e8 + coin_name = getCoinName(coin_type) + self.log.warning( + f"Lite wallet has {balance_btc:.8f} {coin_name} - " + f"will sync keypool and trigger rescan in full node" + ) + return { + "empty": True, + "has_balance": True, + "balance_sats": balance_sats, + "message": ( + f"Lite wallet has {balance_btc:.8f} {coin_name}. " + f"Keypool will be expanded and rescan triggered." + ), + } + finally: + self.closeDB(cursor, commit=False) + except Exception as e: + self.log.debug(f"Error checking lite wallet balance: {e}") + + return {"empty": True} + + except Exception as e: + self.log.error(f"_checkElectrumWalletEmpty error: {e}") + return {"empty": True} + + def _extractAllAddressesFromRPC(self, coin_type: Coins) -> list: + addresses = [] + try: + cc = self.coin_clients[coin_type] + rpchost = cc.get("rpchost", "127.0.0.1") + rpcport = cc.get("rpcport") + if not rpcport: + self.log.debug( + f"_extractAllAddressesFromRPC: no rpcport for {getCoinName(coin_type)}" + ) + return addresses + + from basicswap.rpc import make_rpc_func + + rpcauth = cc.get("rpcauth") + wallet_name = cc.get("wallet_name", "wallet.dat") + rpc_func = make_rpc_func(rpcport, rpcauth, None, rpchost) + + try: + wallets = rpc_func("listwallets", []) + if wallets and wallet_name not in wallets: + for w in wallets: + if w not in ("mweb",): # Skip mweb wallet (todo) + wallet_name = w + self.log.debug( + f"_extractAllAddressesFromRPC: discovered wallet '{wallet_name}'" + ) + break + except Exception as e: + self.log.debug(f"_extractAllAddressesFromRPC: listwallets failed: {e}") + + self.log.debug( + f"_extractAllAddressesFromRPC: querying {getCoinName(coin_type)} " + f"wallet={wallet_name} port={rpcport}" + ) + + wallet_path = f"/wallet/{wallet_name}" + + try: + received = rpc_func("listreceivedbyaddress", [0, True], wallet_path) + for entry in received: + if "address" in entry: + addresses.append(entry["address"]) + self.log.debug( + f"_extractAllAddressesFromRPC: listreceivedbyaddress returned " + f"{len(addresses)} addresses" + ) + except Exception as e: + self.log.debug( + f"_extractAllAddressesFromRPC: listreceivedbyaddress with wallet failed: {e}" + ) + try: + received = rpc_func("listreceivedbyaddress", [0, True]) + for entry in received: + if "address" in entry: + addresses.append(entry["address"]) + except Exception as e2: + self.log.debug( + f"_extractAllAddressesFromRPC: listreceivedbyaddress failed: {e2}" + ) + + try: + groupings = rpc_func("listaddressgroupings", [], wallet_path) + count_before = len(addresses) + for group in groupings: + for item in group: + if len(item) >= 1: + addresses.append(item[0]) + self.log.debug( + f"_extractAllAddressesFromRPC: listaddressgroupings added " + f"{len(addresses) - count_before} addresses" + ) + except Exception as e: + self.log.debug( + f"_extractAllAddressesFromRPC: listaddressgroupings with wallet failed: {e}" + ) + try: + groupings = rpc_func("listaddressgroupings", []) + for group in groupings: + for item in group: + if len(item) >= 1: + addresses.append(item[0]) + except Exception as e2: + self.log.debug( + f"_extractAllAddressesFromRPC: listaddressgroupings failed: {e2}" + ) + + addresses = list(set(addresses)) + + if addresses: + self.log.info( + f"Extracted {len(addresses)} addresses from {getCoinName(coin_type)} RPC" + ) + else: + self.log.debug( + f"_extractAllAddressesFromRPC: no addresses found for {getCoinName(coin_type)}" + ) + + except Exception as e: + self.log.debug(f"_extractAllAddressesFromRPC error: {e}") + + return addresses + + def _syncWalletIndicesToRPC(self, coin_type: Coins) -> dict: + result = {"success": False, "last_index": 0, "error": None} + + if coin_type not in WalletManager.SUPPORTED_COINS: + result["error"] = "Coin not supported" + return result + + try: + if not self._wallet_manager.isInitialized(coin_type): + result["success"] = True + result["error"] = "WalletManager not initialized, nothing to sync" + return result + + from basicswap.db_wallet import WalletState + + cursor = self.openDB() + try: + state = self.queryOne( + WalletState, cursor, {"coin_type": int(coin_type)} + ) + state_last_index = 0 + if state: + state_last_index = max( + state.last_external_index or 0, state.last_internal_index or 0 + ) + + actual_max_external = 0 + actual_max_internal = 0 + try: + row = cursor.execute( + "SELECT MAX(derivation_index) as max_idx FROM wallet_addresses " + "WHERE coin_type = ? AND is_internal = 0", + (int(coin_type),), + ).fetchone() + if row and row[0] is not None: + actual_max_external = row[0] + + row = cursor.execute( + "SELECT MAX(derivation_index) as max_idx FROM wallet_addresses " + "WHERE coin_type = ? AND is_internal = 1", + (int(coin_type),), + ).fetchone() + if row and row[0] is not None: + actual_max_internal = row[0] + except Exception as e: + self.log.debug(f"Error querying wallet_addresses: {e}") + + actual_max_index = max(actual_max_external, actual_max_internal) + last_index = max(state_last_index, actual_max_index) + result["last_index"] = last_index + + if last_index != state_last_index: + self.log.warning( + f"wallet_state index ({state_last_index}) differs from actual " + f"max index ({last_index}) for {getCoinName(coin_type)}" + ) + + if last_index > 0: + try: + cc = self.coin_clients[coin_type] + rpcport = cc.get("rpcport") + if rpcport: + from basicswap.rpc import make_rpc_func + + rpcauth = cc.get("rpcauth") + rpchost = cc.get("rpchost", "127.0.0.1") + wallet_name = cc.get("wallet_name", "wallet.dat") + rpc_func = make_rpc_func(rpcport, rpcauth, None, rpchost) + wallet_path = f"/wallet/{wallet_name}" + keypool_size = last_index + 100 + try: + rpc_func("keypoolrefill", [keypool_size], wallet_path) + self.log.info( + f"Expanded {getCoinName(coin_type)} keypool to {keypool_size}" + ) + result["keypool_expanded"] = keypool_size + except Exception as e: + self.log.debug(f"keypoolrefill failed: {e}") + + try: + try: + rpc_func("rescanblockchain", [], wallet_path) + self.log.info( + f"Triggered blockchain rescan for {getCoinName(coin_type)}" + ) + result["rescan_triggered"] = True + except Exception as e: + if "pruned" in str(e).lower(): + try: + info = rpc_func("getblockchaininfo", []) + pruned_height = info.get("pruneheight", 0) + if pruned_height > 0: + self.log.info( + f"Node is pruned, rescanning from height {pruned_height}" + ) + rpc_func( + "rescanblockchain", + [pruned_height], + wallet_path, + ) + result["rescan_triggered"] = True + except Exception as e2: + self.log.debug( + f"Pruned rescan failed: {e2}" + ) + result["rescan_triggered"] = False + else: + raise + except Exception as e: + self.log.debug(f"rescanblockchain failed: {e}") + result["rescan_triggered"] = False + except Exception as e: + self.log.debug(f"Could not sync keypool: {e}") + + result["success"] = True + finally: + self.closeDB(cursor, commit=False) + + self._clearCachedAddresses(coin_type) + + except Exception as e: + result["error"] = str(e) + self.log.error(f"_syncWalletIndicesToRPC error: {e}") + + return result + + def _syncLiteWalletToRPCOnStartup(self, coin_type: Coins) -> None: + try: + cc = self.coin_clients[coin_type] + if not cc.get("auto_transfer_on_mode_switch", True): + self.log.debug( + f"Auto-transfer disabled for {getCoinName(coin_type)}, skipping sweep check" + ) + return + + cursor = self.openDB() + try: + row = cursor.execute( + """SELECT COUNT(*) as cnt, MAX(derivation_index) as max_idx, + SUM(cached_balance) as total_balance + FROM wallet_addresses + WHERE coin_type = ? AND cached_balance > 0""", + (int(coin_type),), + ).fetchone() + + if not row or row[0] <= 0: + self.log.debug( + f"No funded lite wallet addresses for {getCoinName(coin_type)}, skipping electrum connection" + ) + return + + count = row[0] + _max_idx = row[1] or 0 # noqa: F841 + total_balance = row[2] or 0 + balance_display = total_balance / 1e8 + coin_name = getCoinName(coin_type) + + self.log.info( + f"Found {count} funded lite wallet addresses for {coin_name} " + f"with balance {balance_display:.8f}. Starting background sweep..." + ) + + if not self._wallet_manager.isInitialized(coin_type): + try: + self.initializeWalletManager(coin_type) + except Exception: + self.log.warning( + f"Cannot sweep {coin_name} lite wallet: wallet is locked. " + f"Unlock wallet via UI to automatically sweep funds." + ) + return + + sweep_thread = threading.Thread( + target=self._sweepLiteWalletBackground, + args=(coin_type, coin_name), + name=f"SweepLiteWallet-{coin_name}", + daemon=True, + ) + sweep_thread.start() + self.log.info( + f"Sweep started in background for {coin_name}. Check logs for progress." + ) + + finally: + self.closeDB(cursor, commit=False) + except Exception as e: + self.log.error(f"_syncLiteWalletToRPCOnStartup error: {e}") + + def _sweepLiteWalletBackground(self, coin_type: Coins, coin_name: str) -> None: + try: + result = self._transferLiteWalletBalanceToRPC(coin_type) + if result: + if result.get("skipped"): + reason = result.get("reason") + if reason == "pending_transactions": + self.log.warning( + f"Sweep skipped for {coin_name}: pending transactions. " + f"Wait for confirmations, then restart or lock/unlock wallet." + ) + else: + self.log.warning(f"Sweep skipped for {coin_name}: {reason}") + elif result.get("txid"): + self.log.info( + f"Sweep completed: {result.get('amount', 0) / 1e8:.8f} {coin_name} swept to RPC wallet" + ) + elif result.get("error"): + self.log.warning( + f"Sweep failed for {coin_name}: {result.get('error')}" + ) + except Exception as e: + self.log.error(f"Background sweep error for {coin_name}: {e}") + + def _transferLiteWalletBalanceToRPC(self, coin_type: Coins) -> dict: + try: + + for bid_id, swap in self.swaps_in_progress.items(): + try: + if hasattr(swap, "coin_from") and swap.coin_from == coin_type: + self.log.warning( + f"Skipping transfer: active swap {bid_id.hex()} uses {getCoinName(coin_type)}" + ) + return {"skipped": True, "reason": "active_swap"} + if hasattr(swap, "coin_to") and swap.coin_to == coin_type: + self.log.warning( + f"Skipping transfer: active swap {bid_id.hex()} uses {getCoinName(coin_type)}" + ) + return {"skipped": True, "reason": "active_swap"} + except Exception: + pass + + if not self._wallet_manager.isInitialized(coin_type): + return None + + coin_name = getCoinName(coin_type) + cc = self.coin_clients[coin_type] + ci = cc.get("interface") + if not ci or not hasattr(ci, "_backend") or ci._backend is None: + from basicswap.wallet_backend import ElectrumBackend + + self.log.info(f"[Sweep {coin_name}] Connecting to electrum server...") + + proxy_host = self.tor_proxy_host if self.use_tor_proxy else None + proxy_port = self.tor_proxy_port if self.use_tor_proxy else None + + clearnet_servers = cc.get("electrum_clearnet_servers", None) + onion_servers = cc.get("electrum_onion_servers", None) + + backend = ElectrumBackend( + coin_type, + self.log, + clearnet_servers=clearnet_servers, + onion_servers=onion_servers, + chain=self.chain, + proxy_host=proxy_host, + proxy_port=proxy_port, + ) + else: + backend = ci._backend + + addresses = self._wallet_manager.getAllAddresses( + coin_type, include_watch_only=False + ) + self.log.info( + f"[Sweep {coin_name}] Querying balance for {len(addresses)} lite wallet addresses..." + ) + + confirmed_balance = 0 + unconfirmed_balance = 0 + + try: + detailed_balances = backend.getDetailedBalance(addresses) + for addr, bal in detailed_balances.items(): + confirmed_balance += bal.get("confirmed", 0) + unconfirmed_balance += bal.get("unconfirmed", 0) + except Exception as e: + self.log.debug(f"Error getting detailed balance: {e}") + + if unconfirmed_balance != 0: + self.log.warning( + f"Skipping transfer: {getCoinName(coin_type)} has unconfirmed balance ({unconfirmed_balance}). " + f"Will retry automatically when transactions confirm." + ) + self._pending_sweeps[coin_type] = self.getTime() + return {"skipped": True, "reason": "pending_transactions"} + + if confirmed_balance <= 0: + self.log.debug( + f"No lite wallet balance to transfer for {getCoinName(coin_type)}" + ) + return None + + rpcport = cc.get("rpcport") + if not rpcport: + self.log.warning("No RPC port for transfer destination") + return None + + from basicswap.rpc import make_rpc_func + + rpcauth = cc.get("rpcauth") + rpchost = cc.get("rpchost", "127.0.0.1") + wallet_name = cc.get("wallet_name", "wallet.dat") + rpc_func = make_rpc_func(rpcport, rpcauth, wallet_name, rpchost) + + try: + rpc_address = rpc_func("getnewaddress", []) + except Exception as e: + self.log.error(f"Could not get RPC address: {e}") + return None + + balance_display = confirmed_balance / 1e8 + + self.log.info( + f"Transferring {balance_display} {getCoinName(coin_type)} from lite wallet to RPC wallet: {rpc_address}" + ) + + try: + from coincurve import PrivateKey + from basicswap.contrib.test_framework.messages import ( + CTransaction, + CTxIn, + CTxOut, + COutPoint, + ) + from basicswap.util.crypto import hash160 + from basicswap.contrib.test_framework.script import ( + CScript, + OP_0, + SIGHASH_ALL, + SegwitV0SignatureHash, + ) + + self.log.info( + f"[Sweep {coin_name}] Fetching UTXOs from electrum server..." + ) + try: + utxos = backend.getUnspentOutputs(addresses, min_confirmations=0) + except Exception as e: + self.log.error(f"[Sweep {coin_name}] Error getting UTXOs: {e}") + utxos = [] + + if not utxos: + self.log.info(f"[Sweep {coin_name}] No UTXOs found") + return None + + total_input = sum(u.get("value", 0) for u in utxos) + self.log.info( + f"[Sweep {coin_name}] Found {len(utxos)} UTXOs totaling {total_input} sats" + ) + + estimated_size = len(utxos) * 150 + 40 + fee_rate = 5 + fee = estimated_size * fee_rate + send_amount = total_input - fee + + if send_amount <= 546: + self.log.info( + f"[Sweep {coin_name}] Amount after fees ({send_amount} sats) below dust threshold" + ) + return None + + self.log.info( + f"[Sweep {coin_name}] Creating transaction: {len(utxos)} inputs -> {send_amount} sats + {fee} fee" + ) + + tx = CTransaction() + tx.nVersion = 2 + + for u in utxos: + txid_bytes = bytes.fromhex(u["txid"])[::-1] + outpoint = COutPoint( + int.from_bytes(txid_bytes, "little"), u["vout"] + ) + tx.vin.append(CTxIn(outpoint, b"", 0xFFFFFFFF)) + + from basicswap.contrib import segwit_addr + from basicswap.chainparams import chainparams + + coin_params = chainparams.get(coin_type, {}) + network_params = coin_params.get( + self.chain, coin_params.get("mainnet", {}) + ) + hrp = network_params.get("hrp", "bc") + witver, witprog = segwit_addr.decode(hrp, rpc_address) + if witver is None: + self.log.error(f"Cannot decode address {rpc_address}") + return None + + output_script = CScript([OP_0, bytes(witprog)]) + tx.vout.append(CTxOut(send_amount, output_script)) + + from basicswap.contrib.test_framework.messages import ( + CTxInWitness, + CScriptWitness, + ) + from basicswap.contrib.test_framework.script import ( + OP_DUP, + OP_HASH160, + OP_EQUALVERIFY, + OP_CHECKSIG, + ) + + self.log.info(f"[Sweep {coin_name}] Signing {len(utxos)} inputs...") + for i, u in enumerate(utxos): + addr = u["address"] + privkey_bytes = self._wallet_manager.getPrivateKey(coin_type, addr) + if privkey_bytes is None: + self.log.error( + f"[Sweep {coin_name}] No private key for address {addr[:20]}..." + ) + return None + + privkey = PrivateKey(privkey_bytes) + pubkey = privkey.public_key.format() + pubkey_hash = hash160(pubkey) + + script_code = CScript( + [OP_DUP, OP_HASH160, pubkey_hash, OP_EQUALVERIFY, OP_CHECKSIG] + ) + + sighash = SegwitV0SignatureHash( + script_code, tx, i, SIGHASH_ALL, u["value"] + ) + + signature = privkey.sign(sighash, hasher=None) + signature_der = signature + bytes([SIGHASH_ALL]) + + witness = CTxInWitness() + witness.scriptWitness = CScriptWitness() + witness.scriptWitness.stack = [signature_der, pubkey] + tx.wit.vtxinwit.append(witness) + + tx.rehash() + tx_hex = tx.serialize().hex() + + self.log.info( + f"[Sweep {coin_name}] Broadcasting transaction to network..." + ) + txid_hex = backend.broadcastTransaction(tx_hex) + self.log.info( + f"[Sweep {coin_name}] SUCCESS! Swept {balance_display} {coin_name} to RPC wallet" + ) + self.log.info( + f"[Sweep {coin_name}] TXID: {txid_hex} (1 confirmation required)" + ) + + self._clearCachedAddresses(coin_type) + + self.notify( + NT.SWEEP_COMPLETED, + { + "coin_type": int(coin_type), + "coin_name": coin_name, + "amount": send_amount / 1e8, + "fee": fee / 1e8, + "txid": txid_hex, + "address": rpc_address, + }, + ) + + if coin_type in self._pending_sweeps: + del self._pending_sweeps[coin_type] + + return { + "txid": txid_hex, + "amount": send_amount, + "fee": fee, + "address": rpc_address, + } + + except Exception as e: + self.log.error(f"Failed to transfer to RPC wallet: {e}") + import traceback + + self.log.debug(traceback.format_exc()) + return None + + except Exception as e: + self.log.error(f"_transferLiteWalletBalanceToRPC error: {e}") + return None + + def _retryPendingSweeps(self) -> None: + if not self._pending_sweeps: + return + + now = self.getTime() + coins_to_retry = [] + + for coin_type, last_attempt in list(self._pending_sweeps.items()): + if now - last_attempt >= 300: + coins_to_retry.append(coin_type) + + for coin_type in coins_to_retry: + try: + coin_name = getCoinName(coin_type) + self.log.info(f"Retrying pending sweep for {coin_name}...") + + result = self._transferLiteWalletBalanceToRPC(coin_type) + if result: + if result.get("skipped"): + self._pending_sweeps[coin_type] = now + self.log.debug( + f"Sweep still pending for {coin_name}: {result.get('reason')}" + ) + elif result.get("txid"): + self.log.info( + f"Pending sweep completed for {coin_name}. TXID: {result.get('txid')}" + ) + else: + if coin_type in self._pending_sweeps: + del self._pending_sweeps[coin_type] + except Exception as e: + self.log.error( + f"Error retrying sweep for {getCoinName(coin_type)}: {e}" + ) + self._pending_sweeps[coin_type] = now + + def getLiteWalletBalanceInfo(self, coin_type: Coins) -> dict: + try: + if not self._wallet_manager: + return None + + cc = self.coin_clients.get(coin_type) + if not cc: + return None + + if cc.get("connection_type") == "electrum": + return None + + ci = self.ci(coin_type) + if not hasattr(ci, "_backend") or not ci._backend: + return None + + backend = ci._backend + addresses = self._wallet_manager.getAllAddresses( + coin_type, include_watch_only=False + ) + + if not addresses: + return None + + confirmed_balance = 0 + unconfirmed_balance = 0 + + try: + detailed_balances = backend.getDetailedBalance(addresses) + for addr, bal in detailed_balances.items(): + confirmed_balance += bal.get("confirmed", 0) + unconfirmed_balance += bal.get("unconfirmed", 0) + except Exception as e: + self.log.debug(f"Error getting lite wallet balance: {e}") + return None + + total_balance = confirmed_balance + unconfirmed_balance + if total_balance <= 0: + return None + + is_pending = coin_type in self._pending_sweeps + + return { + "confirmed": confirmed_balance / 1e8, + "unconfirmed": unconfirmed_balance / 1e8, + "total": total_balance / 1e8, + "is_pending_sweep": is_pending, + "address_count": len(addresses), + } + except Exception as e: + self.log.debug(f"getLiteWalletBalanceInfo error: {e}") + return None + + def _tryGetFullNodeAddresses(self, coin_type: Coins) -> list: + addresses = [] + try: + cc = self.coin_clients[coin_type] + + if cc.get("connection_type") == "electrum": + return addresses + + rpchost = cc.get("rpchost", "127.0.0.1") + rpcport = cc.get("rpcport") + if not rpcport: + return addresses + + from basicswap.rpc import make_rpc_func + + rpcauth = cc.get("rpcauth") + wallet_name = cc.get("wallet_name", "wallet.dat") + + rpc_func = make_rpc_func(rpcport, rpcauth, None, rpchost) + + try: + wallet_path = f"/wallet/{wallet_name}" + received = rpc_func("listreceivedbyaddress", [0, True], wallet_path) + for entry in received: + if "address" in entry: + addresses.append(entry["address"]) + except Exception: + try: + received = rpc_func("listreceivedbyaddress", [0, True]) + for entry in received: + if "address" in entry: + addresses.append(entry["address"]) + except Exception: + pass + + if addresses: + self.log.debug( + f"Found {len(addresses)} addresses from {Coins(coin_type).name} full node" + ) + except Exception as e: + self.log.debug(f"Full node not accessible for {Coins(coin_type).name}: {e}") + + return addresses + + def initializeWalletManager(self, coin_type: Coins) -> bool: + if coin_type not in WalletManager.SUPPORTED_COINS: + return False + + if self._wallet_manager.isInitialized(coin_type): + return True + + try: + is_encrypted, is_locked = self.getLockedState() + if is_locked: + self.log.debug( + f"PART wallet is locked, cannot initialize WalletManager for {Coins(coin_type).name}" + ) + return False + except Exception as e: + self.log.warning(f"Could not check locked state: {e}") + return False + + try: + root_key = self.getWalletKey(coin_type, 1) + self._wallet_manager.initialize(coin_type, root_key) + self.log.info(f"WalletManager initialized for {Coins(coin_type).name}") + return True + except Exception as e: + self.log.error( + f"Failed to initialize WalletManager for {Coins(coin_type).name}: {e}" + ) + if self.debug: + self.log.error(traceback.format_exc()) + return False + + def getWalletManager(self) -> WalletManager: + return self._wallet_manager + def lockWallets(self, coin=None) -> None: try: self._read_zmq_queue = False @@ -1623,6 +2961,27 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): legacy_root_hash = ci.getSeedHash(root_key, 20) self.setStringKV(key_str, legacy_root_hash.hex(), cursor) + if coin_type in (Coins.LTC, Coins.BTC): + from basicswap.contrib.test_framework.script import hash160 + from basicswap.util.extkey import ExtKeyPair + + ek = ExtKeyPair() + ek.set_seed(root_key) + electrum_seed_id = hash160(ek.encode_p()).hex() + + rpc_seed_id = ci.getAddressHashFromKey(root_key)[::-1].hex() + + if seed_id.hex() == electrum_seed_id: + alt_seed_id = rpc_seed_id + else: + alt_seed_id = electrum_seed_id + + key_str = "main_wallet_seedid_alt_" + db_key_coin_name + self.setStringKV(key_str, alt_seed_id, cursor) + self.log.debug( + f"Stored both seed ID formats for {ci.coin_name()}: primary={seed_id.hex()[:16]}..., alt={alt_seed_id[:16]}..." + ) + def initialiseWallet( self, interface_type, raise_errors: bool = False, restore_time: int = -1 ) -> None: @@ -1887,6 +3246,25 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): elif SwapTypes.SELLER_FIRST: pass # No prevouts are locked + for coin_type in (Coins(offer.coin_from), Coins(offer.coin_to)): + try: + ci = self.ci(coin_type) + wm = ( + ci.getWalletManager() + if hasattr(ci, "getWalletManager") + else None + ) + if wm: + unlocked = wm.unlockUTXOsForBid(coin_type, bid.bid_id) + if unlocked > 0: + self.log.debug( + f"Unlocked {unlocked} electrum UTXOs for {coin_type.name} on bid deactivation" + ) + except Exception as e: + self.log.debug( + f"Failed to unlock electrum UTXOs for {coin_type}: {e}" + ) + # Update identity stats if bid.state in ( BidStates.BID_ERROR, @@ -2024,6 +3402,15 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): if self.ws_server and show_event: event_data["event"] = "update_available" self.ws_server.send_message_to_all(json.dumps(event_data)) + elif event_type == NT.SWEEP_COMPLETED: + coin_name = event_data.get("coin_name", "Unknown") + amount = event_data.get("amount", 0) + self.log.info( + f"Sweep completed: {amount} {coin_name} swept to RPC wallet" + ) + if self.ws_server and show_event: + event_data["event"] = "sweep_completed" + self.ws_server.send_message_to_all(json.dumps(event_data)) else: self.log.warning(f"Unknown notification {event_type}") @@ -2342,6 +3729,11 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): try: if ci.interface_type() in self.scriptless_coins: ci.ensureFunds(ensure_balance + estimated_fee) + elif ci.useBackend(): + self.log.debug( + f"Electrum mode: skipping balance check for {ci.coin_name()}" + ) + pass else: pi = self.pi(swap_type) _ = pi.getFundedInitiateTxTemplate(ci, ensure_balance, False) @@ -2879,15 +4271,21 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): {"coin_type": int(coin_type), "bid_id": None}, ) if not record: - address = self.getReceiveAddressForCoin(coin_type) + ci = self.ci(coin_type) + wm = ci.getWalletManager() if hasattr(ci, "getWalletManager") else None + if wm and ci._connection_type == "electrum": + address = wm.getNewAddress( + coin_type, internal=False, cursor=use_cursor + ) + else: + address = self.getReceiveAddressForCoin(coin_type) + self.log.debug( + f"getReceiveAddressFromPool: got new address {self.log.addr(address)}" + ) record = PooledAddress(addr=address, coin_type=int(coin_type)) record.bid_id = bid_id record.tx_type = tx_type addr = record.addr - ensure( - self.ci(coin_type).isAddressMine(addr), - "Pool address not owned by wallet!", - ) self.add(record, use_cursor, upsert=True) self.commitDB() finally: @@ -3000,6 +4398,33 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self.setStringKV(key_str, addr, cursor) return addr + def _clearCachedAddresses(self, coin_type: Coins) -> None: + ci = self.ci(coin_type) + coin_name = ci.coin_name().lower() + + key_str = "receive_addr_" + coin_name + self.clearStringKV(key_str) + self.log.debug(f"Cleared cached receive address for {coin_name}") + + main_key = "main_wallet_addr_" + coin_name + self.clearStringKV(main_key) + self.log.debug(f"Cleared cached main wallet address for {coin_name}") + + try: + cursor = self.openDB() + try: + cursor.execute( + "UPDATE wallet_addresses SET cached_balance = 0 WHERE coin_type = ?", + (int(coin_type),), + ) + self.log.debug( + f"Cleared cached balances in wallet_addresses for {coin_name}" + ) + finally: + self.closeDB(cursor, commit=True) + except Exception as e: + self.log.debug(f"Error clearing cached balances: {e}") + def getCachedMainWalletAddress(self, ci, cursor=None): db_key = "main_wallet_addr_" + ci.coin_name().lower() cached_addr = self.getStringKV(db_key, cursor) @@ -3035,6 +4460,14 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self.log.warning(msg) return False + if hasattr(ci, "useBackend") and ci.useBackend() and self._wallet_manager: + if self._wallet_manager.isInitialized(c): + ci.setWalletSeedWarning(False) + self.log.debug( + f"checkWalletSeed {ci.coin_name()}: electrum mode, seed derived from master key" + ) + return True + seed_key: str = "main_wallet_seedid_" + ci.coin_name().lower() expect_seedid: str = self.getStringKV(seed_key) if expect_seedid is None: @@ -3053,21 +4486,55 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self.log.warning("Node is locked.") return False - if c == Coins.BTC and len(ci.rpc("listwallets")) < 1: - self.log.warning(f"Missing wallet for coin {ci.coin_name()}") - return False + if c == Coins.BTC and not ci.useBackend(): + if len(ci.rpc("listwallets")) < 1: + self.log.warning(f"Missing wallet for coin {ci.coin_name()}") + return False + try: + wallet_seedid = ci.getWalletSeedID() + except Exception as e: + self.log.debug( + f"checkWalletSeed {ci.coin_name()}: getWalletSeedID failed: {e}" + ) + wallet_seedid = None if ci.checkExpectedSeed(expect_seedid): ci.setWalletSeedWarning(False) return True - if c == Coins.DCR: - # Try the legacy extkey - expect_seedid = self.getStringKV( + if c in (Coins.DCR, Coins.LTC, Coins.BTC): + alt_seedid = self.getStringKV( "main_wallet_seedid_alt_" + ci.coin_name().lower() ) - if ci.checkExpectedSeed(expect_seedid): + if alt_seedid and ci.checkExpectedSeed(alt_seedid): ci.setWalletSeedWarning(False) - self.log.warning(f"{ci.coin_name()} is using the legacy extkey.") return True + + _, is_locked = self.getLockedState() + if not is_locked and c in (Coins.LTC, Coins.BTC): + try: + from basicswap.contrib.test_framework.script import hash160 + from basicswap.util.extkey import ExtKeyPair + + root_key = self.getWalletKey(c, 1) + + ek = ExtKeyPair() + ek.set_seed(root_key) + electrum_seedid = hash160(ek.encode_p()).hex() + rpc_seedid = ci.getAddressHashFromKey(root_key)[::-1].hex() + + self.log.debug( + f"checkWalletSeed {ci.coin_name()}: computed electrum={electrum_seedid[:16]}..., rpc={rpc_seedid[:16]}..." + ) + + if wallet_seedid in (electrum_seedid, rpc_seedid): + self.log.info( + f"Auto-fixing seed ID for {ci.coin_name()} - wallet matches computed format" + ) + self.storeSeedIDForCoin(root_key, c) + ci.setWalletSeedWarning(False) + return True + except Exception as e: + self.log.debug(f"Auto-fix seed ID failed: {e}") + self.log.warning( f"Wallet for coin {ci.coin_name()} not derived from swap seed." ) @@ -3092,10 +4559,15 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): raise ValueError("Wallet seed doesn't match expected.") def getCachedAddressForCoin(self, coin_type, cursor=None): - self.log.debug(f"getCachedAddressForCoin {Coins(coin_type).name}") # TODO: auto refresh after used ci = self.ci(coin_type) + + if hasattr(ci, "useBackend") and ci.useBackend() and self._wallet_manager: + addr = self._wallet_manager.getDepositAddress(coin_type) + if addr: + return addr + key_str = "receive_addr_" + ci.coin_name().lower() use_cursor = self.openDB(cursor) try: @@ -3120,7 +4592,6 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): return addr def getCachedStealthAddressForCoin(self, coin_type, cursor=None): - self.log.debug(f"getCachedStealthAddressForCoin {Coins(coin_type).name}") if coin_type == Coins.LTC_MWEB: coin_type = Coins.LTC @@ -3177,7 +4648,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): f"getProofOfFunds {ci.coin_name()} {ci.format_amount(amount_for)}" ) - if self.coin_clients[coin_type]["connection_type"] != "rpc": + if self.coin_clients[coin_type]["connection_type"] not in ("rpc", "electrum"): return (None, None, None) return ci.getProofOfFunds(amount_for, extra_commit_bytes) @@ -5036,7 +6507,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): def createInitiateTxn( self, coin_type, bid_id: bytes, bid, initiate_script, prefunded_tx=None ) -> (Optional[str], Optional[int]): - if self.coin_clients[coin_type]["connection_type"] != "rpc": + if self.coin_clients[coin_type]["connection_type"] not in ("rpc", "electrum"): return None, None ci = self.ci(coin_type) @@ -5136,7 +6607,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): coin_to = Coins(offer.coin_to) - if self.coin_clients[coin_to]["connection_type"] != "rpc": + if self.coin_clients[coin_to]["connection_type"] not in ("rpc", "electrum"): return None ci = self.ci(coin_to) @@ -5173,7 +6644,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): bid.participate_txn_refund = bytes.fromhex(refund_txn) chain_height = ci.getChainHeight() - txjs = self.callcoinrpc(coin_to, "decoderawtransaction", [txn_signed]) + txjs = ci.decodeRawTransaction(txn_signed) txid = txjs["txid"] if ci.using_segwit(): @@ -5232,7 +6703,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): secret = self.getContractSecret(bid_date, bid.contract_count) ensure(len(secret) == 32, "Bad secret length") - if self.coin_clients[coin_type]["connection_type"] != "rpc": + if self.coin_clients[coin_type]["connection_type"] not in ("rpc", "electrum"): return None if fee_rate is None: @@ -5358,7 +6829,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): cursor=None, ): self.log.debug(f"createRefundTxn for coin {Coins(coin_type).name}") - if self.coin_clients[coin_type]["connection_type"] != "rpc": + if self.coin_clients[coin_type]["connection_type"] not in ("rpc", "electrum"): return None ci = self.ci(coin_type) @@ -5658,6 +7129,8 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): def getTotalBalance(self, coin_type) -> int: try: ci = self.ci(coin_type) + if self.coin_clients[coin_type].get("connection_type") == "electrum": + return ci.getSpendableBalance() if hasattr(ci, "rpc_wallet"): if coin_type in (Coins.XMR, Coins.WOW): balance_info = ci.rpc_wallet("get_balance") @@ -5698,7 +7171,8 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): return self.lookupUnspentByAddress(coin_type, address, sum_output=True) def lookupChainHeight(self, coin_type) -> int: - return self.callcoinrpc(coin_type, "getblockcount") + ci = self.ci(coin_type) + return ci.getChainHeight() def lookupUnspentByAddress( self, @@ -5736,9 +7210,9 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): ) ) - if self.coin_clients[coin_type]["connection_type"] != "rpc": + if self.coin_clients[coin_type]["connection_type"] not in ("rpc", "electrum"): raise ValueError( - "No RPC connection for lookupUnspentByAddress {}".format( + "No connection for lookupUnspentByAddress {}".format( Coins(coin_type).name ) ) @@ -5852,6 +7326,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): bid_id=bid.bid_id, tx_type=TxTypes.XMR_SWAP_B_LOCK, txid=xmr_swap.b_lock_tx_id, + vout=0, ) if bid.xmr_b_lock_tx.txid != found_txid: self.log.debug( @@ -5865,6 +7340,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): def checkXmrBidState(self, bid_id: bytes, bid, offer): rv = False + state = BidStates(bid.state) reverse_bid: bool = self.is_reverse_ads_bid(offer.coin_from, offer.coin_to) ci_from = self.ci(offer.coin_to if reverse_bid else offer.coin_from) @@ -6034,6 +7510,12 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self.log.info( f"Found coin b lock recover tx bid {self.log.id(bid_id)}" ) + self.logBidEvent( + bid.bid_id, + EventLogTypes.LOCK_TX_B_REFUND_TX_SEEN, + "", + cursor, + ) rv = True # Remove from swaps_in_progress bid.setState(BidStates.XMR_SWAP_FAILED_REFUNDED) self.saveBidInSession(bid_id, bid, cursor, xmr_swap) @@ -6261,53 +7743,96 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): BidStates.XMR_SWAP_SCRIPT_COIN_LOCKED, BidStates.XMR_SWAP_SCRIPT_TX_PREREFUND, ): - bid_changed = self.findTxB(ci_to, xmr_swap, bid, cursor, was_sent) + try: + bid_changed = self.findTxB(ci_to, xmr_swap, bid, cursor, was_sent) + except Exception as e: + if ci_to.is_transient_error(e): + rpc_error_count = self.countBidEvents( + bid, EventLogTypes.LOCK_TX_B_RPC_ERROR, cursor + ) + if rpc_error_count < 10: + self.log.warning( + f"Bid {self.log.id(bid_id)}: Temporary RPC error checking lock tx B ({rpc_error_count + 1}/10): {e}" + ) + self.logBidEvent( + bid.bid_id, + EventLogTypes.LOCK_TX_B_RPC_ERROR, + str(e), + cursor, + ) + else: + self.log.error( + f"Bid {self.log.id(bid_id)}: Too many consecutive RPC errors ({rpc_error_count}), aborting swap" + ) + bid.setState(BidStates.BID_ERROR) + self.logBidEvent( + bid.bid_id, + EventLogTypes.ERROR, + f"Persistent RPC error after {rpc_error_count} attempts: {e}", + cursor, + ) + bid_changed = True + else: + raise if ( bid.xmr_b_lock_tx and bid.xmr_b_lock_tx.chain_height is not None and bid.xmr_b_lock_tx.chain_height > 0 ): - chain_height = ci_to.getChainHeight() + chain_height = None + try: + chain_height = ci_to.getChainHeight() + except Exception as e: + if ci_to.is_transient_error(e): + self.log.warning( + f"Bid {self.log.id(bid_id)}: Temporary RPC error getting chain height: {e}" + ) + else: + raise - if bid.debug_ind == DebugTypes.BID_STOP_AFTER_COIN_B_LOCK: - self.log.debug( - f"Adaptor-sig bid {self.log.id(bid_id)}: Stalling bid for testing: {bid.debug_ind}." - ) - bid.setState(BidStates.BID_STALLED_FOR_TEST) - self.logBidEvent( - bid.bid_id, - EventLogTypes.DEBUG_TWEAK_APPLIED, - f"ind {bid.debug_ind}", - cursor, - ) - elif ( - bid.xmr_b_lock_tx.state != TxStates.TX_CONFIRMED - and chain_height - bid.xmr_b_lock_tx.chain_height - >= ci_to.blocks_confirmed - ): - self.logBidEvent( - bid.bid_id, EventLogTypes.LOCK_TX_B_CONFIRMED, "", cursor - ) - bid.xmr_b_lock_tx.setState(TxStates.TX_CONFIRMED) - bid.setState(BidStates.XMR_SWAP_NOSCRIPT_COIN_LOCKED) + if chain_height is not None: + if bid.debug_ind == DebugTypes.BID_STOP_AFTER_COIN_B_LOCK: + self.log.debug( + f"Adaptor-sig bid {self.log.id(bid_id)}: Stalling bid for testing: {bid.debug_ind}." + ) + bid.setState(BidStates.BID_STALLED_FOR_TEST) + self.logBidEvent( + bid.bid_id, + EventLogTypes.DEBUG_TWEAK_APPLIED, + f"ind {bid.debug_ind}", + cursor, + ) + elif ( + bid.xmr_b_lock_tx.state != TxStates.TX_CONFIRMED + and chain_height - bid.xmr_b_lock_tx.chain_height + >= ci_to.blocks_confirmed + ): + self.logBidEvent( + bid.bid_id, + EventLogTypes.LOCK_TX_B_CONFIRMED, + "", + cursor, + ) + bid.xmr_b_lock_tx.setState(TxStates.TX_CONFIRMED) + bid.setState(BidStates.XMR_SWAP_NOSCRIPT_COIN_LOCKED) - if was_received: - if TxTypes.XMR_SWAP_A_LOCK_REFUND in bid.txns: - self.log.warning( - f"Not releasing ads script coin lock tx for bid {self.log.id(bid_id)}: Chain A lock refund tx already exists." - ) - else: - delay = self.get_delay_event_seconds() - self.log.info( - f"Releasing ads script coin lock tx for bid {self.log.id(bid_id)} in {delay} seconds." - ) - self.createActionInSession( - delay, - ActionTypes.SEND_XMR_LOCK_RELEASE, - bid_id, - cursor, - ) + if was_received: + if TxTypes.XMR_SWAP_A_LOCK_REFUND in bid.txns: + self.log.warning( + f"Not releasing ads script coin lock tx for bid {self.log.id(bid_id)}: Chain A lock refund tx already exists." + ) + else: + delay = self.get_delay_event_seconds() + self.log.info( + f"Releasing ads script coin lock tx for bid {self.log.id(bid_id)} in {delay} seconds." + ) + self.createActionInSession( + delay, + ActionTypes.SEND_XMR_LOCK_RELEASE, + bid_id, + cursor, + ) if bid_changed: self.saveBidInSession(bid_id, bid, cursor, xmr_swap) @@ -6319,12 +7844,16 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): if was_received: try: txn_hex = ci_from.getMempoolTx(xmr_swap.a_lock_spend_tx_id) - self.log.info( - f"Found lock spend txn in {ci_from.coin_name()} mempool, {self.logIDT(xmr_swap.a_lock_spend_tx_id)}" - ) - self.process_XMR_SWAP_A_LOCK_tx_spend( - bid_id, xmr_swap.a_lock_spend_tx_id.hex(), txn_hex, cursor - ) + if txn_hex: + self.log.info( + f"Found lock spend txn in {ci_from.coin_name()} mempool, {self.logIDT(xmr_swap.a_lock_spend_tx_id)}" + ) + self.process_XMR_SWAP_A_LOCK_tx_spend( + bid_id, + xmr_swap.a_lock_spend_tx_id.hex(), + txn_hex, + cursor, + ) except Exception as e: self.log.debug(f"getrawtransaction lock spend tx failed: {e}") elif state == BidStates.XMR_SWAP_SCRIPT_TX_REDEEMED: @@ -6368,6 +7897,9 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self.log.info( f"Found coin b lock spend tx bid {self.log.id(bid_id)}" ) + self.logBidEvent( + bid.bid_id, EventLogTypes.LOCK_TX_B_SPEND_TX_SEEN, "", cursor + ) rv = True # Remove from swaps_in_progress bid.setState(BidStates.SWAP_COMPLETED) self.saveBidInSession(bid_id, bid, cursor, xmr_swap) @@ -6855,12 +8387,26 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): ) # TODO: Wait for depth? bid.setITxState(TxStates.TX_REFUNDED) + self.logEvent( + Concepts.BID, + bid.bid_id, + EventLogTypes.ITX_REFUND_PUBLISHED, + "", + None, + ) else: self.log.info( f"Bid {self.log.id(bid_id)} initiate txn redeemed by {self.logIDT(spend_txid)} {spend_n}." ) # TODO: Wait for depth? bid.setITxState(TxStates.TX_REDEEMED) + self.logEvent( + Concepts.BID, + bid.bid_id, + EventLogTypes.ITX_REDEEM_PUBLISHED, + "", + None, + ) self.removeWatchedOutput(coin_from, bid_id, bid.initiate_tx.txid.hex()) self.saveBid(bid_id, bid) @@ -6890,6 +8436,13 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): ) # TODO: Wait for depth? bid.setPTxState(TxStates.TX_REFUNDED) + self.logEvent( + Concepts.BID, + bid.bid_id, + EventLogTypes.PTX_REFUND_PUBLISHED, + "", + None, + ) else: self.log.debug( f"Secret {secret.hex()} extracted from participate spend {self.logIDT(spend_txid)} {spend_n}" @@ -6977,6 +8530,13 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): if bid.xmr_a_lock_tx: bid.xmr_a_lock_tx.setState(TxStates.TX_REDEEMED) + self.logBidEvent( + bid.bid_id, + EventLogTypes.LOCK_TX_A_SPEND_TX_SEEN, + "", + use_cursor, + ) + if not was_received: bid.setState(BidStates.SWAP_COMPLETED) try: @@ -7269,6 +8829,9 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): ) bid.xmr_a_lock_tx.txid = txid bid.xmr_a_lock_tx.vout = vout + self.logBidEvent( + watched_script.bid_id, EventLogTypes.LOCK_TX_A_SEEN, "", None + ) self.saveBid(watched_script.bid_id, bid) elif watched_script.tx_type == TxTypes.XMR_SWAP_B_LOCK: self.log.info( @@ -7288,6 +8851,9 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): bid.xmr_b_lock_tx.txid = txid bid.xmr_b_lock_tx.vout = vout bid.xmr_b_lock_tx.setState(TxStates.TX_IN_CHAIN) + self.logBidEvent( + watched_script.bid_id, EventLogTypes.LOCK_TX_B_SEEN, "", None + ) self.saveBid(watched_script.bid_id, bid) else: self.log.warning( @@ -7397,6 +8963,9 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): # assert (self.mxDB.locked()) self.log.debug(f"checkForSpends {Coins(coin_type).name}.") + if self.coin_clients[coin_type].get("connection_type") == "electrum": + return self.checkForSpendsElectrum(coin_type, c) + # TODO: Check for spends on watchonly txns where possible if self.coin_clients[coin_type].get("have_spent_index", False): # TODO: batch getspentinfo @@ -7513,6 +9082,80 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): last_height_checked += 1 self.updateCheckedBlock(ci, c, block) + def checkForSpendsElectrum(self, coin_type, c): + ci = self.ci(coin_type) + chain_blocks = ci.getChainHeight() + + num_outputs = len(c["watched_outputs"]) + num_scripts = len(c["watched_scripts"]) + if num_outputs > 0 or num_scripts > 0: + self.log.debug( + f"checkForSpendsElectrum {ci.coin_name()}: watching {num_outputs} outputs, {num_scripts} scripts at height {chain_blocks}" + ) + + for o in list(c["watched_outputs"]): + try: + self.log.debug(f"Checking output {o.txid_hex}:{o.vout} for spend") + spend_info = ci.checkWatchedOutput(o.txid_hex, o.vout) + if spend_info: + self.log.debug( + f"Found spend via Electrum {self.logIDT(o.txid_hex)} {o.vout} in {self.logIDT(spend_info['txid'])} {spend_info['vin']}" + ) + raw_tx = ci.getBackend().getTransactionRaw(spend_info["txid"]) + if not raw_tx: + self.log.debug( + f"Failed to get spend tx {spend_info['txid']}, will retry" + ) + continue + tx = ci.loadTx(bytes.fromhex(raw_tx)) + vin_list = [] + for idx, inp in enumerate(tx.vin): + vin_entry = { + "txid": f"{inp.prevout.hash:064x}", + "vout": inp.prevout.n, + } + if tx.wit and idx < len(tx.wit.vtxinwit): + wit = tx.wit.vtxinwit[idx] + if wit.scriptWitness and wit.scriptWitness.stack: + vin_entry["txinwitness"] = [ + item.hex() for item in wit.scriptWitness.stack + ] + vin_list.append(vin_entry) + tx_dict = { + "txid": spend_info["txid"], + "hex": raw_tx, + "vin": vin_list, + "vout": [ + { + "value": ci.format_amount(out.nValue), + "n": i, + "scriptPubKey": {"hex": out.scriptPubKey.hex()}, + } + for i, out in enumerate(tx.vout) + ], + } + self.processSpentOutput( + coin_type, o, spend_info["txid"], spend_info["vin"], tx_dict + ) + else: + self.log.debug(f"No spend found for {o.txid_hex}:{o.vout}") + except Exception as e: + self.log.debug(f"checkWatchedOutput error: {e}") + + for s in list(c["watched_scripts"]): + try: + found = ci.checkWatchedScript(s.script) + if found: + txid_bytes = bytes.fromhex(found["txid"]) + self.log.debug( + f"Found script via Electrum for bid {self.log.id(s.bid_id)}: {self.logIDT(txid_bytes)} {found['vout']}." + ) + self.processFoundScript(coin_type, s, txid_bytes, found["vout"]) + except Exception as e: + self.log.debug(f"checkWatchedScript error: {e}") + + c["last_height_checked"] = chain_blocks + def expireMessageRoutes(self) -> None: if self._is_locked is True: self.log.debug("Not expiring message routes while system is locked") @@ -9598,6 +11241,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): bid_id=bid_id, tx_type=TxTypes.XMR_SWAP_B_LOCK, txid=b_lock_tx_id, + vout=0, ) xmr_swap.b_lock_tx_id = b_lock_tx_id bid.xmr_b_lock_tx.setState(TxStates.TX_SENT) @@ -9681,6 +11325,14 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): ) return + # Check if already redeemed + if bid.xmr_a_lock_spend_tx and bid.xmr_a_lock_spend_tx.txid: + self.log.debug( + f"Coin A lock tx already redeemed for bid {self.log.id(bid_id)}, " + f"txid: {bid.xmr_a_lock_spend_tx.txid.hex()}" + ) + return + reverse_bid: bool = self.is_reverse_ads_bid(offer.coin_from, offer.coin_to) coin_from = Coins(offer.coin_to if reverse_bid else offer.coin_from) coin_to = Coins(offer.coin_from if reverse_bid else offer.coin_to) @@ -9794,6 +11446,13 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): ensure(bid, f"Bid not found: {self.log.id(bid_id)}.") ensure(xmr_swap, f"Adaptor-sig swap not found: {self.log.id(bid_id)}.") + if bid.xmr_b_lock_tx and bid.xmr_b_lock_tx.spend_txid: + self.log.debug( + f"Coin B lock tx already redeemed for bid {self.log.id(bid_id)}, " + f"txid: {bid.xmr_b_lock_tx.spend_txid.hex()}" + ) + return + offer, xmr_offer = self.getXmrOfferFromSession(cursor, bid.offer_id) ensure(offer, f"Offer not found: {self.log.id(bid.offer_id)}.") ensure(xmr_offer, f"Adaptor-sig offer not found: {self.log.id(bid.offer_id)}.") @@ -10282,6 +11941,10 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): if bid.state == BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_TX_SIGS: bid.setState(BidStates.XMR_SWAP_HAVE_SCRIPT_COIN_SPEND_TX) bid.setState(BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_SPEND_TX) + elif bid.state == BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_SPEND_TX: + self.log.debug( + f"processXmrBidLockSpendTx bid {self.log.id(bid_id)} already in state {bid.state}." + ) else: self.log.warning( f"processXmrBidLockSpendTx bid {self.log.id(bid_id)} unexpected state {bid.state}." @@ -11028,6 +12691,23 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): for bid_id, bid, offer in to_remove: self.deactivateBid(None, offer, bid) + + if self._wallet_manager: + for bid_id, v in self.swaps_in_progress.items(): + bid, offer = v + try: + for coin_type in ( + Coins(offer.coin_from), + Coins(offer.coin_to), + ): + cc = self.coin_clients.get(coin_type) + if cc and cc.get("connection_type") == "electrum": + self._wallet_manager.extendLocksForBid( + coin_type, bid.bid_id, extend_seconds=3600 + ) + except Exception as e: + self.log.debug(f"Failed to extend UTXO locks for bid: {e}") + self._last_checked_progress = now if now - self._last_checked_watched >= self.check_watched_seconds: @@ -11049,6 +12729,14 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self.checkAcceptedBids() self._last_checked_expired = now + if self._wallet_manager: + try: + self._wallet_manager.cleanupExpiredLocks() + self._wallet_manager.cleanupExpiredTxCache() + self._wallet_manager.cleanupConfirmedTxs() + except Exception as e: + self.log.debug(f"Wallet manager cleanup error: {e}") + if self._max_logfile_bytes > 0: logfile_size: int = self.fp.tell() self.log.debug(f"Log file bytes: {logfile_size}.") @@ -11116,6 +12804,10 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): if now - self._last_checked_updates >= self.check_updates_seconds: self.checkForUpdates() + if now - self._last_checked_pending_sweeps >= 60: + self._retryPendingSweeps() + self._last_checked_pending_sweeps = now + except Exception as ex: self.logException(f"update {ex}") @@ -11374,6 +13066,69 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self.log.info(f"Updating settings {coin_name}.") settings_changed = False suggest_reboot = False + migration_message = None + + if "connection_type" in data: + new_connection_type = data["connection_type"] + old_connection_type = self.settings["chainclients"][coin_name].get( + "connection_type" + ) + if old_connection_type != new_connection_type: + coin_id = self.getCoinIdFromName(coin_name) + display_name = getCoinName(coin_id) + + if old_connection_type == "rpc" and new_connection_type == "electrum": + migration_result = self._migrateWalletToLiteMode(coin_id) + if migration_result.get("success"): + count = migration_result.get("count", 0) + self.log.info( + f"Lite wallet ready for {coin_name} with {count} addresses" + ) + migration_message = ( + f"Lite wallet ready for {display_name} ({count} addresses)." + ) + else: + error = migration_result.get("error", "unknown") + reason = migration_result.get("reason", "") + + if reason in ( + "has_balance", + "active_swap", + "pending_transactions", + ): + self.log.error( + f"Migration blocked for {coin_name}: {error}" + ) + raise ValueError(error) + self.log.warning( + f"Wallet migration warning for {coin_name}: {error}" + ) + migration_message = f"Migration warning: {error}" + + elif old_connection_type == "electrum" and new_connection_type == "rpc": + + empty_check = self._checkElectrumWalletEmpty(coin_id) + if not empty_check.get("empty", False): + error = empty_check.get( + "message", "Wallet must be empty before switching modes" + ) + reason = empty_check.get("reason", "") + if reason in ("has_balance", "active_swap"): + self.log.error( + f"Migration blocked for {coin_name}: {error}" + ) + raise ValueError(error) + + sync_result = self._syncWalletIndicesToRPC(coin_id) + if sync_result.get("success"): + last_index = sync_result.get("last_index", 0) + self.log.info( + f"Synced wallet indices for {coin_name} to RPC (index: {last_index})" + ) + migration_message = ( + f"Synced {display_name} wallet indices to full node." + ) + settings_copy = copy.deepcopy(self.settings) with self.mxDB: settings_cc = settings_copy["chainclients"][coin_name] @@ -11474,7 +13229,62 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): settings_changed = True settings_cc["wallet_pwd"] = new_wallet_pwd + if "connection_type" in data: + new_connection_type = data["connection_type"] + old_connection_type = settings_cc.get("connection_type") + if old_connection_type != new_connection_type: + settings_changed = True + suggest_reboot = True + settings_cc["connection_type"] = new_connection_type + + def parse_servers_text(text): + servers = [] + for line in text.split("\n"): + line = line.strip() + if not line: + continue + servers.append(line) + return servers + + if "electrum_clearnet_servers" in data: + new_clearnet = parse_servers_text(data["electrum_clearnet_servers"]) + current_clearnet = settings_cc.get("electrum_clearnet_servers", None) + if new_clearnet != current_clearnet: + settings_changed = True + suggest_reboot = True + settings_cc["electrum_clearnet_servers"] = new_clearnet + for coin, cc in self.coin_clients.items(): + if cc["name"] == coin_name: + cc["electrum_clearnet_servers"] = new_clearnet + break + + if "electrum_onion_servers" in data: + new_onion = parse_servers_text(data["electrum_onion_servers"]) + current_onion = settings_cc.get("electrum_onion_servers", None) + if new_onion != current_onion: + settings_changed = True + suggest_reboot = True + settings_cc["electrum_onion_servers"] = new_onion + for coin, cc in self.coin_clients.items(): + if cc["name"] == coin_name: + cc["electrum_onion_servers"] = new_onion + break + + if "auto_transfer_on_mode_switch" in data: + new_auto_transfer = data["auto_transfer_on_mode_switch"] + if ( + settings_cc.get("auto_transfer_on_mode_switch", True) + != new_auto_transfer + ): + settings_changed = True + settings_cc["auto_transfer_on_mode_switch"] = new_auto_transfer + for coin, cc in self.coin_clients.items(): + if cc["name"] == coin_name: + cc["auto_transfer_on_mode_switch"] = new_auto_transfer + break + if settings_changed: + self._normalizeSettingsPaths(settings_copy) settings_path = os.path.join(self.data_dir, cfg.CONFIG_FILENAME) settings_path_new = settings_path + ".new" shutil.copyfile(settings_path, settings_path + ".last") @@ -11482,7 +13292,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): json.dump(settings_copy, fp, indent=4) shutil.move(settings_path_new, settings_path) self.settings = settings_copy - return settings_changed, suggest_reboot + return settings_changed, suggest_reboot, migration_message def enableCoin(self, coin_name: str) -> None: self.log.info(f"Enabling coin {coin_name}.") @@ -11520,7 +13330,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): settings_cc = self.settings["chainclients"][coin_name] - if settings_cc["connection_type"] != "rpc": + if settings_cc["connection_type"] not in ("rpc", "electrum"): raise ValueError("Already disabled.") settings_cc["manage_daemon_prev"] = settings_cc["manage_daemon"] @@ -11606,8 +13416,14 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): try: blockchaininfo = ci.getBlockchainInfo() + if self.coin_clients[coin].get("connection_type") == "electrum": + version = ci.getDaemonVersion() + self.coin_clients[coin]["core_version"] = version + else: + version = self.coin_clients[coin]["core_version"] + rv = { - "version": self.coin_clients[coin]["core_version"], + "version": version, "name": ci.coin_name(), "blocks": blockchaininfo["blocks"], "synced": "{:.2f}".format( @@ -11615,6 +13431,11 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): ), } + if hasattr(ci, "getElectrumServer"): + server = ci.getElectrumServer() + if server: + rv["electrum_server"] = server + if "known_block_count" in blockchaininfo: rv["known_block_count"] = blockchaininfo["known_block_count"] if "bootstrapping" in blockchaininfo: @@ -11638,6 +13459,9 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): "expected_seed": ci.knownWalletSeed(), "encrypted": walletinfo["encrypted"], "locked": walletinfo["locked"], + "connection_type": self.coin_clients[coin].get( + "connection_type", "rpc" + ), } if "wallet_blocks" in walletinfo: @@ -11664,18 +13488,20 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): elif coin == Coins.NAV: rv["immature"] = walletinfo["immature_balance"] elif coin == Coins.LTC: - try: - rv["mweb_address"] = self.getCachedStealthAddressForCoin( - Coins.LTC_MWEB - ) - except Exception as e: - self.log.warning( - f"getCachedStealthAddressForCoin for {ci.coin_name()} failed with: {e}." - ) - rv["mweb_balance"] = walletinfo["mweb_balance"] - rv["mweb_pending"] = ( - walletinfo["mweb_unconfirmed"] + walletinfo["mweb_immature"] - ) + if self.coin_clients[coin].get("connection_type") != "electrum": + try: + rv["mweb_address"] = self.getCachedStealthAddressForCoin( + Coins.LTC_MWEB + ) + except Exception as e: + self.log.warning( + f"getCachedStealthAddressForCoin for {ci.coin_name()} failed with: {e}." + ) + if "mweb_balance" in walletinfo: + rv["mweb_balance"] = walletinfo["mweb_balance"] + rv["mweb_pending"] = walletinfo.get( + "mweb_unconfirmed", 0 + ) + walletinfo.get("mweb_immature", 0) return rv except Exception as e: @@ -11706,19 +13532,98 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): def updateWalletInfo(self, coin) -> None: # Store wallet info to db so it's available after startup try: + ci = self.ci(coin) + is_electrum = hasattr(ci, "useBackend") and ci.useBackend() + bi = self.getBlockchainInfo(coin) if bi: self.addWalletInfoRecord(coin, 0, bi) - # monero-wallet-rpc is slow/unresponsive while syncing wi = self.getWalletInfo(coin) if wi: self.addWalletInfoRecord(coin, 1, wi) + + if not is_electrum and coin in WalletManager.SUPPORTED_COINS: + self._syncAddressesFromFullNode(coin) except Exception as e: self.log.error(f"updateWalletInfo {e}.") finally: self._updating_wallets_info[int(coin)] = False + def _syncAddressesFromFullNode(self, coin_type: Coins) -> int: + if coin_type in self._synced_addresses_from_full_node: + return 0 + + cc = self.coin_clients.get(coin_type) + if not cc or cc.get("connection_type") != "rpc": + return 0 + + addresses = self._tryGetFullNodeAddresses(coin_type) + if not addresses: + return 0 + + imported = 0 + for addr in addresses: + try: + self._wallet_manager.importWatchOnlyAddress( + coin_type, addr, source="full_node_sync" + ) + imported += 1 + except Exception: + pass + + if imported > 0: + self.log.debug( + f"Synced {imported} addresses from {Coins(coin_type).name} full node" + ) + + self._synced_addresses_from_full_node.add(coin_type) + return imported + + def rescanWalletAddresses(self, coin_type: Coins) -> dict: + if coin_type not in WalletManager.SUPPORTED_COINS: + return {"success": False, "error": "Coin not supported for rescan"} + + try: + self._wallet_manager.resetMigration(coin_type) + + cursor = self.openDB() + try: + self.execute( + cursor, + "DELETE FROM wallet_watch_only WHERE coin_type = ?", + (int(coin_type),), + ) + self.commitDB() + finally: + self.closeDB(cursor, commit=False) + + self._wallet_manager._initialized[coin_type] = False + + full_node_addresses = self._tryGetFullNodeAddresses(coin_type) + + ci = self.ci(coin_type) + key_str = "receive_addr_" + ci.coin_name().lower() + cached_address = self.getStringKV(key_str) + + root_key = self.getWalletKey(coin_type, 1) + self._wallet_manager.initialize(coin_type, root_key) + + added = self._wallet_manager.runMigration( + coin_type, + full_node_addresses=full_node_addresses, + cached_address=cached_address, + ) + + return { + "success": True, + "addresses_imported": added, + "full_node_available": len(full_node_addresses) > 0, + } + except Exception as e: + self.log.error(f"rescanWalletAddresses error: {e}") + return {"success": False, "error": str(e)} + def updateWalletsInfo( self, force_update: bool = False, @@ -11728,36 +13633,98 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): now: int = self.getTime() if not force_update and now - self._last_updated_wallets_info < 30: return + + futures = [] for c in Coins: if only_coin is not None and c != only_coin: continue if c not in chainparams: continue cc = self.coin_clients[c] - if cc["connection_type"] == "rpc": + if cc["connection_type"] in ("rpc", "electrum"): if ( not force_update and now - cc.get("last_updated_wallet_info", 0) < 30 ): - return + continue cc["last_updated_wallet_info"] = self.getTime() self._updating_wallets_info[int(c)] = True handle = self.thread_pool.submit(self.updateWalletInfo, c) - if wait_for_complete: + futures.append((c, handle)) + + if wait_for_complete and futures: + from concurrent.futures import as_completed, TimeoutError + + future_map = {f: c for c, f in futures} + try: + for future in as_completed( + future_map.keys(), timeout=self._wallet_update_timeout + ): try: - handle.result(timeout=self._wallet_update_timeout) + future.result() except Exception as e: - self.log.error(f"updateWalletInfo {e}.") + coin = future_map[future] + self.log.error(f"updateWalletInfo {coin}: {e}.") + except TimeoutError: + pending = [ + chainparams[c]["ticker"] + for f, c in future_map.items() + if not f.done() + ] + self.log.warning( + f"Wallet update timeout ({self._wallet_update_timeout}s), " + f"pending: {', '.join(pending)}" + ) def getWalletsInfo(self, opts=None): rv = {} - for c in self.activeCoins(): - key = chainparams[c]["ticker"] if opts.get("ticker_key", False) else c + active_coins = list(self.activeCoins()) + + def fetch_wallet_info(coin): + key = ( + chainparams[coin]["ticker"] + if opts and opts.get("ticker_key", False) + else coin + ) try: - rv[key] = self.getWalletInfo(c) - rv[key].update(self.getBlockchainInfo(c)) + info = self.getWalletInfo(coin) + info.update(self.getBlockchainInfo(coin)) + return (key, info) except Exception as ex: - rv[key] = {"name": getCoinName(c), "error": str(ex)} + return (key, {"name": getCoinName(coin), "error": str(ex)}) + + from concurrent.futures import as_completed, TimeoutError + + futures = { + self.thread_pool.submit(fetch_wallet_info, c): c for c in active_coins + } + + try: + for future in as_completed(futures, timeout=self._wallet_update_timeout): + try: + key, info = future.result() + rv[key] = info + except Exception as ex: + coin = futures[future] + key = ( + chainparams[coin]["ticker"] + if opts and opts.get("ticker_key", False) + else coin + ) + rv[key] = {"name": getCoinName(coin), "error": str(ex)} + except TimeoutError: + for future, coin in futures.items(): + if not future.done(): + key = ( + chainparams[coin]["ticker"] + if opts and opts.get("ticker_key", False) + else coin + ) + rv[key] = {"name": getCoinName(coin), "error": "Timeout"} + self.log.warning( + f"getWalletsInfo: {chainparams[coin]['ticker']} timed out" + ) + return rv def getCachedWalletsInfo(self, opts=None): @@ -12044,7 +14011,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): for c, v in self.coin_clients.items(): if c in (Coins.PART_ANON, Coins.PART_BLIND): # exclude duplicates continue - if self.coin_clients[c]["connection_type"] == "rpc": + if self.coin_clients[c]["connection_type"] in ("rpc", "electrum"): rv_heights.append((c, v["last_height_checked"])) for o in v["watched_outputs"]: rv.append((c, o.bid_id, o.txid_hex, o.vout, o.tx_type)) @@ -12532,15 +14499,39 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): return chainparams[use_coinid]["name"] def _backgroundPriceFetchLoop(self): + backoff_until = 0 + backoff_multiplier = 1 + while self._price_fetch_running: try: now = int(time.time()) + + if now < backoff_until: + for _ in range(60): + if not self._price_fetch_running: + break + time.sleep(1) + continue + if now - self._last_price_fetch >= self.price_fetch_interval: - self._fetchPricesBackground() - self._last_price_fetch = now - if now - self._last_volume_fetch >= self.volume_fetch_interval: - self._fetchVolumeBackground() - self._last_volume_fetch = now + try: + self._fetchPricesAndVolumeBackground() + self._last_price_fetch = now + self._last_volume_fetch = now + backoff_multiplier = 1 + except Exception as e: + error_str = str(e) + if "429" in error_str or "Too Many Requests" in error_str: + backoff_seconds = min(120 * backoff_multiplier, 960) + backoff_until = now + backoff_seconds + backoff_multiplier = min(backoff_multiplier * 2, 8) + self.log.warning( + f"CoinGecko rate limited, backing off for {backoff_seconds}s" + ) + else: + self.log.warning( + f"Background price/volume fetch failed: {e}" + ) except Exception as e: self.log.error(f"Background price/volume fetch error: {e}") @@ -12549,6 +14540,14 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): break time.sleep(1) + def _fetchPricesAndVolumeBackground(self): + all_coins = [c for c in Coins if c in chainparams] + if not all_coins: + return + + for rate_source in ["coingecko.com"]: + self._fetchPricesAndVolumeForSource(all_coins, rate_source, Fiat.USD) + def _fetchPricesBackground(self): all_coins = [c for c in Coins if c in chainparams] if not all_coins: @@ -12720,6 +14719,117 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): finally: self.closeDB(cursor, commit=False) + def _fetchPricesAndVolumeForSource(self, coins_list, rate_source, currency_to): + now = int(time.time()) + headers = {"User-Agent": "Mozilla/5.0", "Connection": "close"} + + exchange_name_map = {} + coin_ids = "" + for coin_id in coins_list: + if len(coin_ids) > 0: + coin_ids += "," + exchange_name = self.getExchangeName(coin_id, rate_source) + coin_ids += exchange_name + exchange_name_map[exchange_name] = coin_id + + if rate_source == "coingecko.com": + ticker_to = fiatTicker(currency_to).lower() + api_key = get_api_key_setting( + self.settings, + "coingecko_api_key", + default_coingecko_api_key, + escape=True, + ) + url = f"https://api.coingecko.com/api/v3/simple/price?ids={coin_ids}&vs_currencies={ticker_to}&include_24hr_vol=true&include_24hr_change=true" + if api_key != "": + url += f"&api_key={api_key}" + + js = json.loads(self.readURL(url, timeout=5, headers=headers)) + + with self._price_cache_lock: + for k, v in js.items(): + coin_id = exchange_name_map[k] + price_cache_key = (coin_id, currency_to, rate_source) + if ticker_to in v: + self._price_cache[price_cache_key] = { + "rate": v[ticker_to], + "timestamp": now, + } + volume_cache_key = (coin_id, rate_source) + volume_24h = v.get(f"{ticker_to}_24h_vol") + price_change_24h = v.get(f"{ticker_to}_24h_change") + self._volume_cache[volume_cache_key] = { + "volume_24h": ( + float(volume_24h) if volume_24h is not None else None + ), + "price_change_24h": ( + float(price_change_24h) + if price_change_24h is not None + else 0.0 + ), + "timestamp": now, + } + + cursor = self.openDB() + try: + update_query = """ + UPDATE coinrates SET + rate=:rate, + last_updated=:last_updated + WHERE currency_from = :currency_from AND currency_to = :currency_to AND source = :rate_source + """ + insert_query = """INSERT INTO coinrates(currency_from, currency_to, rate, source, last_updated) + VALUES(:currency_from, :currency_to, :rate, :rate_source, :last_updated)""" + + for k, v in js.items(): + coin_id = exchange_name_map[k] + if ticker_to in v: + cursor.execute( + update_query, + { + "currency_from": coin_id, + "currency_to": currency_to, + "rate": v[ticker_to], + "rate_source": rate_source, + "last_updated": now, + }, + ) + if cursor.rowcount < 1: + cursor.execute( + insert_query, + { + "currency_from": coin_id, + "currency_to": currency_to, + "rate": v[ticker_to], + "rate_source": rate_source, + "last_updated": now, + }, + ) + + for k, v in js.items(): + coin_id = exchange_name_map[k] + volume_24h = v.get(f"{ticker_to}_24h_vol") + price_change_24h = v.get(f"{ticker_to}_24h_change") + cursor.execute( + "INSERT OR REPLACE INTO coinvolume (coin_id, volume_24h, price_change_24h, source, last_updated) VALUES (:coin_id, :volume_24h, :price_change_24h, :rate_source, :last_updated)", + { + "coin_id": coin_id, + "volume_24h": ( + str(volume_24h) if volume_24h is not None else "None" + ), + "price_change_24h": ( + str(price_change_24h) + if price_change_24h is not None + else "0.0" + ), + "rate_source": rate_source, + "last_updated": now, + }, + ) + self.commitDB() + finally: + self.closeDB(cursor, commit=False) + def lookupFiatRates( self, coins_list, @@ -12727,8 +14837,6 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): rate_source: str = "coingecko.com", saved_ttl: int = 300, ): - coins_list_display = ", ".join([Coins(c).name for c in coins_list]) - self.log.debug(f"lookupFiatRates {coins_list_display}.") ensure(len(coins_list) > 0, "Must specify coin/s") ensure(saved_ttl >= 0, "Invalid saved time") @@ -12782,8 +14890,6 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): rate_source: str = "coingecko.com", saved_ttl: int = 300, ): - coins_list_display = ", ".join([Coins(c).name for c in coins_list]) - self.log.debug(f"lookupVolume {coins_list_display}.") ensure(len(coins_list) > 0, "Must specify coin/s") ensure(saved_ttl >= 0, "Invalid saved time") @@ -12856,8 +14962,6 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): rate_source: str = "coingecko.com", saved_ttl: int = 3600, ): - coins_list_display = ", ".join([Coins(c).name for c in coins_list]) - self.log.debug(f"lookupHistoricalData {coins_list_display}, days={days}.") ensure(len(coins_list) > 0, "Must specify coin/s") ensure(saved_ttl >= 0, "Invalid saved time") ensure(days > 0, "Days must be positive") @@ -13032,12 +15136,10 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): return rv def ws_new_client(self, client, server): - self.log.debug(f'ws_new_client {client["id"]}') + pass def ws_client_left(self, client, server): - if client is None: - return - self.log.debug(f'ws_client_left {client["id"]}') + pass def ws_message_received(self, client, server, message): if len(message) > 200: diff --git a/basicswap/basicswap_util.py b/basicswap/basicswap_util.py index c388bf1..5c0f17f 100644 --- a/basicswap/basicswap_util.py +++ b/basicswap/basicswap_util.py @@ -212,6 +212,10 @@ class EventLogTypes(IntEnum): BCH_MERCY_TX_FOUND = auto() LOCK_TX_A_IN_MEMPOOL = auto() LOCK_TX_A_CONFLICTS = auto() + LOCK_TX_B_RPC_ERROR = auto() + LOCK_TX_A_SPEND_TX_SEEN = auto() + LOCK_TX_B_SPEND_TX_SEEN = auto() + LOCK_TX_B_REFUND_TX_SEEN = auto() class XmrSplitMsgTypes(IntEnum): @@ -247,6 +251,7 @@ class NotificationTypes(IntEnum): BID_ACCEPTED = auto() SWAP_COMPLETED = auto() UPDATE_AVAILABLE = auto() + SWEEP_COMPLETED = auto() class ConnectionRequestTypes(IntEnum): @@ -458,6 +463,8 @@ def describeEventEntry(event_type, event_msg): return "Failed to publish lock tx B refund" if event_type == EventLogTypes.LOCK_TX_B_INVALID: return "Detected invalid lock Tx B" + if event_type == EventLogTypes.LOCK_TX_B_RPC_ERROR: + return "Temporary RPC error checking lock tx B: " + event_msg if event_type == EventLogTypes.LOCK_TX_A_REFUND_TX_PUBLISHED: return "Lock tx A pre-refund tx published" if event_type == EventLogTypes.LOCK_TX_A_REFUND_SPEND_TX_PUBLISHED: @@ -498,6 +505,12 @@ def describeEventEntry(event_type, event_msg): return "BCH mercy tx found" if event_type == EventLogTypes.BCH_MERCY_TX_PUBLISHED: return "Lock tx B mercy tx published" + if event_type == EventLogTypes.LOCK_TX_A_SPEND_TX_SEEN: + return "Lock tx A spend tx seen in chain" + if event_type == EventLogTypes.LOCK_TX_B_SPEND_TX_SEEN: + return "Lock tx B spend tx seen in chain" + if event_type == EventLogTypes.LOCK_TX_B_REFUND_TX_SEEN: + return "Lock tx B refund tx seen in chain" def getVoutByAddress(txjs, p2sh): diff --git a/basicswap/bin/prepare.py b/basicswap/bin/prepare.py index eff3377..8612ee2 100755 --- a/basicswap/bin/prepare.py +++ b/basicswap/bin/prepare.py @@ -1748,6 +1748,13 @@ def printHelp(): ) print("--client-auth-password= Set or update the password to protect the web UI.") print("--disable-client-auth Remove password protection from the web UI.") + print( + "--light Use light wallet mode (Electrum) for all supported coins." + ) + print("--btc-mode=MODE Set BTC connection mode: rpc, electrum, or remote.") + print("--ltc-mode=MODE Set LTC connection mode: rpc, electrum, or remote.") + print("--btc-electrum-server= Custom Electrum server for BTC (host:port:ssl).") + print("--ltc-electrum-server= Custom Electrum server for LTC (host:port:ssl).") active_coins = [] for coin_name in known_coins.keys(): @@ -1955,6 +1962,11 @@ def initialise_wallets( hex_seed = swap_client.getWalletKey(Coins.DCR, 1).hex() createDCRWallet(args, hex_seed, logger, threading.Event()) continue + if coin_settings.get("connection_type") == "electrum": + logger.info( + f"Skipping RPC wallet creation for {getCoinName(c)} (electrum mode)." + ) + continue swap_client.waitForDaemonRPC(c, with_wallet=False) # Create wallet if it doesn't exist yet wallets = swap_client.callcoinrpc(c, "listwallets") @@ -2052,7 +2064,11 @@ def initialise_wallets( c = swap_client.getCoinIdFromName(coin_name) if c in (Coins.PART,): continue - if c not in (Coins.DCR,): + if coin_settings.get("connection_type") == "electrum": + logger.info( + f"Skipping daemon RPC wait for {getCoinName(c)} (electrum mode)." + ) + elif c not in (Coins.DCR,): # initialiseWallet only sets main_wallet_seedid_ swap_client.waitForDaemonRPC(c) try: @@ -2279,6 +2295,9 @@ def main(): tor_control_password = None client_auth_pwd_value = None disable_client_auth_flag = False + light_mode = False + coin_modes = {} + electrum_servers = {} extra_opts = {} if os.getenv("SSL_CERT_DIR", "") == "" and GUIX_SSL_CERT_DIR is not None: @@ -2433,6 +2452,32 @@ def main(): if name == "disable-client-auth": disable_client_auth_flag = True continue + if name == "light": + light_mode = True + continue + if name.endswith("-mode") and len(s) == 2: + coin_prefix = name[:-5] + mode_value = s[1].strip().lower() + if mode_value not in ("rpc", "electrum", "remote"): + exitWithError( + f"Invalid mode '{mode_value}' for {coin_prefix}. Use: rpc, electrum, or remote" + ) + coin_modes[coin_prefix] = mode_value + continue + if name.endswith("-electrum-server") and len(s) == 2: + coin_prefix = name[:-16] + server_str = s[1].strip() + parts = server_str.split(":") + if len(parts) >= 2: + server = { + "host": parts[0], + "port": int(parts[1]), + "ssl": parts[2].lower() == "true" if len(parts) > 2 else True, + } + if coin_prefix not in electrum_servers: + electrum_servers[coin_prefix] = [] + electrum_servers[coin_prefix].append(server) + continue if len(s) != 2: exitWithError("Unknown argument {}".format(v)) exitWithError("Unknown argument {}".format(v)) @@ -2791,6 +2836,32 @@ def main(): }, } + electrum_supported_coins = { + "bitcoin": "btc", + "litecoin": "ltc", + } + + for coin_name, coin_prefix in electrum_supported_coins.items(): + if coin_name not in chainclients: + continue + + use_electrum = False + if light_mode and coin_name != "particl": + use_electrum = True + if coin_prefix in coin_modes: + if coin_modes[coin_prefix] == "electrum": + use_electrum = True + elif coin_modes[coin_prefix] == "rpc": + use_electrum = False + + if use_electrum: + chainclients[coin_name]["connection_type"] = "electrum" + chainclients[coin_name]["manage_daemon"] = False + if coin_prefix in electrum_servers: + chainclients[coin_name]["electrum_servers"] = electrum_servers[ + coin_prefix + ] + for coin_name, coin_settings in chainclients.items(): coin_id = getCoinIdFromName(coin_name) coin_params = chainparams[coin_id] diff --git a/basicswap/bin/run.py b/basicswap/bin/run.py index d67859f..d60707e 100755 --- a/basicswap/bin/run.py +++ b/basicswap/bin/run.py @@ -303,21 +303,24 @@ def getCoreBinArgs(coin_id: int, coin_settings, prepare=False, use_tor_proxy=Fal if prepare is False and use_tor_proxy: if coin_id == Coins.BCH: # Without this BCH (27.1) will bind to the default BTC port, even with proxy set - extra_args.append("--bind=127.0.0.1:" + str(int(coin_settings["port"]))) + port: int = int(coin_settings["port"]) + onionport: int = coin_settings.get("onionport", 8335) + extra_args.append(f"--bind=127.0.0.1:{port}") + extra_args.append(f"--bind=127.0.0.1:{onionport}=onion") else: extra_args.append("--port=" + str(int(coin_settings["port"]))) - # BTC versions from v28 fail to start if the onionport is in use. # As BCH may use port 8334, disable it here. # When tor is enabled a bind option for the onionport will be added to bitcoin.conf. # https://github.com/bitcoin/bitcoin/blob/master/doc/release-notes/release-notes-28.0.md?plain=1#L84 - if ( - prepare is False - and use_tor_proxy is False - and coin_id in (Coins.BTC, Coins.NMC) - ): + if prepare is False and coin_id in (Coins.BTC, Coins.NMC): port: int = coin_settings.get("port", 8333) - extra_args.append(f"--bind=0.0.0.0:{port}") + if use_tor_proxy: + onionport: int = coin_settings.get("onionport", 8334) + extra_args.append(f"--bind=0.0.0.0:{port}") + extra_args.append(f"--bind=127.0.0.1:{onionport}=onion") + else: + extra_args.append(f"--bind=0.0.0.0:{port}") return extra_args diff --git a/basicswap/db.py b/basicswap/db.py index b1e53f6..a42cb2b 100644 --- a/basicswap/db.py +++ b/basicswap/db.py @@ -13,7 +13,7 @@ from enum import IntEnum, auto from typing import Optional -CURRENT_DB_VERSION = 33 +CURRENT_DB_VERSION = 34 CURRENT_DB_DATA_VERSION = 7 @@ -772,8 +772,13 @@ class NetworkPortal(Table): created_at = Column("integer") -def extract_schema(input_globals=None) -> dict: - g = globals() if input_globals is None else input_globals +def extract_schema(extra_tables: list = None) -> dict: + g = globals().copy() + + if extra_tables: + for table_class in extra_tables: + g[table_class.__name__] = table_class + tables = {} for name, obj in g.items(): if not inspect.isclass(obj): @@ -893,18 +898,18 @@ def create_table(c, table_name, table) -> None: c.execute(query) -def create_db_(con, log) -> None: - db_schema = extract_schema() +def create_db_(con, log, extra_tables: list = None) -> None: + db_schema = extract_schema(extra_tables=extra_tables) c = con.cursor() for table_name, table in db_schema.items(): create_table(c, table_name, table) -def create_db(db_path: str, log) -> None: +def create_db(db_path: str, log, extra_tables: list = None) -> None: con = None try: con = sqlite3.connect(db_path) - create_db_(con, log) + create_db_(con, log, extra_tables=extra_tables) con.commit() finally: if con: @@ -912,42 +917,63 @@ def create_db(db_path: str, log) -> None: class DBMethods: + _db_lock_depth = 0 + + def _db_lock_held(self) -> bool: + + if hasattr(self.mxDB, "_is_owned"): + return self.mxDB._is_owned() + return self.mxDB.locked() + def openDB(self, cursor=None): if cursor: - # assert(self._thread_debug == threading.get_ident()) - assert self.mxDB.locked() + assert self._db_lock_held() return cursor + if self._db_lock_held(): + self._db_lock_depth += 1 + return self._db_con.cursor() + self.mxDB.acquire() - # self._thread_debug = threading.get_ident() + self._db_lock_depth = 1 self._db_con = sqlite3.connect(self.sqlite_file) + + self._db_con.execute("PRAGMA busy_timeout = 30000") return self._db_con.cursor() def getNewDBCursor(self): - assert self.mxDB.locked() + assert self._db_lock_held() return self._db_con.cursor() def commitDB(self): - assert self.mxDB.locked() + assert self._db_lock_held() self._db_con.commit() def rollbackDB(self): - assert self.mxDB.locked() + assert self._db_lock_held() self._db_con.rollback() def closeDBCursor(self, cursor): - assert self.mxDB.locked() + assert self._db_lock_held() if cursor: cursor.close() def closeDB(self, cursor, commit=True): - assert self.mxDB.locked() + assert self._db_lock_held() + + if self._db_lock_depth > 1: + if commit: + self._db_con.commit() + cursor.close() + self._db_lock_depth -= 1 + return if commit: self._db_con.commit() cursor.close() self._db_con.close() + self._db_lock_depth = 0 self.mxDB.release() def setIntKV(self, str_key: str, int_val: int, cursor=None) -> None: @@ -1037,7 +1063,7 @@ class DBMethods: ) finally: if cursor is None: - self.closeDB(use_cursor, commit=False) + self.closeDB(use_cursor, commit=True) def add(self, obj, cursor, upsert: bool = False, columns_list=None): if cursor is None: @@ -1201,6 +1227,9 @@ class DBMethods: query: str = f"UPDATE {table_name} SET " values = {} + constraint_values = {} + set_columns = [] + for mc in inspect.getmembers(obj.__class__): mc_name, mc_obj = mc if not hasattr(mc_obj, "__sqlite3_column__"): @@ -1212,18 +1241,19 @@ class DBMethods: continue if mc_name in constraints: - values[mc_name] = m_obj + constraint_values[mc_name] = m_obj continue if columns_list is not None and mc_name not in columns_list: continue - if len(values) > 0: - query += ", " - query += f"{mc_name} = :{mc_name}" + + set_columns.append(f"{mc_name} = :{mc_name}") values[mc_name] = m_obj + query += ", ".join(set_columns) query += " WHERE 1=1 " for ck in constraints: query += f" AND {ck} = :{ck} " + values.update(constraint_values) cursor.execute(query, values) diff --git a/basicswap/db_upgrades.py b/basicswap/db_upgrades.py index 89ccaff..9bbddba 100644 --- a/basicswap/db_upgrades.py +++ b/basicswap/db_upgrades.py @@ -18,6 +18,15 @@ from .db import ( extract_schema, ) +from .db_wallet import ( + WalletAddress, + WalletLockedUTXO, + WalletPendingTx, + WalletState, + WalletTxCache, + WalletWatchOnly, +) + from .basicswap_util import ( BidStates, canAcceptBidState, @@ -260,7 +269,16 @@ def upgradeDatabase(self, db_version: int): ), ] - expect_schema = extract_schema() + wallet_tables = [ + WalletAddress, + WalletLockedUTXO, + WalletPendingTx, + WalletState, + WalletTxCache, + WalletWatchOnly, + ] + expect_schema = extract_schema(extra_tables=wallet_tables) + have_tables = {} try: cursor = self.openDB() for rename_column in rename_columns: @@ -269,7 +287,93 @@ def upgradeDatabase(self, db_version: int): cursor.execute( f"ALTER TABLE {table_name} RENAME COLUMN {colname_from} TO {colname_to}" ) - upgradeDatabaseFromSchema(self, cursor, expect_schema) + + query = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;" + tables = cursor.execute(query).fetchall() + for table in tables: + table_name = table[0] + if table_name in ("sqlite_sequence",): + continue + + have_table = {} + have_columns = {} + query = "SELECT * FROM PRAGMA_TABLE_INFO(:table_name) ORDER BY cid DESC;" + columns = cursor.execute(query, {"table_name": table_name}).fetchall() + for column in columns: + cid, name, data_type, notnull, default_value, primary_key = column + have_columns[name] = {"type": data_type, "primary_key": primary_key} + + have_table["columns"] = have_columns + + cursor.execute(f"PRAGMA INDEX_LIST('{table_name}');") + indices = cursor.fetchall() + for index in indices: + seq, index_name, unique, origin, partial = index + + if origin == "pk": + continue + + cursor.execute(f"PRAGMA INDEX_INFO('{index_name}');") + index_info = cursor.fetchall() + + add_index = {"index_name": index_name} + for index_columns in index_info: + seqno, cid, name = index_columns + if origin == "u": + have_columns[name]["unique"] = 1 + else: + if "column_1" not in add_index: + add_index["column_1"] = name + elif "column_2" not in add_index: + add_index["column_2"] = name + elif "column_3" not in add_index: + add_index["column_3"] = name + else: + raise RuntimeError("Add more index columns.") + if origin == "c": + if "indices" not in have_table: + have_table["indices"] = [] + have_table["indices"].append(add_index) + + have_tables[table_name] = have_table + + for table_name, table in expect_schema.items(): + if table_name not in have_tables: + self.log.info(f"Creating table {table_name}.") + create_table(cursor, table_name, table) + continue + + have_table = have_tables[table_name] + have_columns = have_table["columns"] + for colname, column in table["columns"].items(): + if colname not in have_columns: + col_type = column["type"] + self.log.info(f"Adding column {colname} to table {table_name}.") + cursor.execute( + f"ALTER TABLE {table_name} ADD COLUMN {colname} {col_type}" + ) + indices = table.get("indices", []) + have_indices = have_table.get("indices", []) + for index in indices: + index_name = index["index_name"] + if not any( + have_idx.get("index_name") == index_name + for have_idx in have_indices + ): + self.log.info(f"Adding index {index_name} to table {table_name}.") + column_1 = index["column_1"] + column_2 = index.get("column_2", None) + column_3 = index.get("column_3", None) + query: str = ( + f"CREATE INDEX {index_name} ON {table_name} ({column_1}" + ) + if column_2: + query += f", {column_2}" + if column_3: + query += f", {column_3}" + query += ")" + cursor.execute(query) + if CURRENT_DB_VERSION != db_version: self.db_version = CURRENT_DB_VERSION self.setIntKV("db_version", CURRENT_DB_VERSION, cursor) diff --git a/basicswap/db_wallet.py b/basicswap/db_wallet.py new file mode 100644 index 0000000..1642b41 --- /dev/null +++ b/basicswap/db_wallet.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024-2026 The Basicswap developers +# Distributed under the MIT software license, see the accompanying +# file LICENSE or http://www.opensource.org/licenses/mit-license.php. + + +from .db import Column, Index, Table, UniqueConstraint + + +class WalletAddress(Table): + + __tablename__ = "wallet_addresses" + + record_id = Column("integer", primary_key=True, autoincrement=True) + coin_type = Column("integer") + derivation_index = Column("integer") + is_internal = Column("bool") + derivation_path = Column("string") + address = Column("string") + scripthash = Column("string") + pubkey = Column("blob") + is_funded = Column("bool") + cached_balance = Column("integer") + cached_balance_time = Column("integer") + first_seen_height = Column("integer") + created_at = Column("integer") + + __unique_1__ = UniqueConstraint("coin_type", "derivation_index", "is_internal") + __index_address__ = Index("idx_wallet_address", "address") + __index_scripthash__ = Index("idx_wallet_scripthash", "scripthash") + __index_funded__ = Index("idx_wallet_funded", "coin_type", "is_funded") + + +class WalletState(Table): + + __tablename__ = "wallet_state" + + coin_type = Column("integer", primary_key=True) + last_external_index = Column("integer") + last_internal_index = Column("integer") + derivation_path_type = Column("string") + last_sync_height = Column("integer") + migration_complete = Column("bool") + created_at = Column("integer") + updated_at = Column("integer") + + +class WalletWatchOnly(Table): + + __tablename__ = "wallet_watch_only" + + record_id = Column("integer", primary_key=True, autoincrement=True) + coin_type = Column("integer") + address = Column("string") + scripthash = Column("string") + label = Column("string") + source = Column("string") + is_funded = Column("bool") + cached_balance = Column("integer") + cached_balance_time = Column("integer") + private_key_encrypted = Column("blob") + created_at = Column("integer") + + __unique_1__ = UniqueConstraint("coin_type", "address") + __index_watch_address__ = Index("idx_watch_address", "address") + __index_watch_scripthash__ = Index("idx_watch_scripthash", "scripthash") + + +class WalletLockedUTXO(Table): + + __tablename__ = "wallet_locked_utxos" + + record_id = Column("integer", primary_key=True, autoincrement=True) + coin_type = Column("integer") + txid = Column("string") + vout = Column("integer") + value = Column("integer") + address = Column("string") + bid_id = Column("blob") + locked_at = Column("integer") + expires_at = Column("integer") + + __unique_1__ = UniqueConstraint("coin_type", "txid", "vout") + __index_locked_coin__ = Index("idx_locked_coin", "coin_type") + __index_locked_bid__ = Index("idx_locked_bid", "bid_id") + + +class WalletTxCache(Table): + + __tablename__ = "wallet_tx_cache" + + record_id = Column("integer", primary_key=True, autoincrement=True) + coin_type = Column("integer") + txid = Column("string") + block_height = Column("integer") + confirmations = Column("integer") + tx_data = Column("blob") + cached_at = Column("integer") + expires_at = Column("integer") + + __unique_1__ = UniqueConstraint("coin_type", "txid") + __index_tx_cache__ = Index("idx_tx_cache", "coin_type", "txid") + + +class WalletPendingTx(Table): + + __tablename__ = "wallet_pending_txs" + + record_id = Column("integer", primary_key=True, autoincrement=True) + coin_type = Column("integer") + txid = Column("string") + tx_type = Column("string") + amount = Column("integer") + fee = Column("integer") + addresses = Column("string") + bid_id = Column("blob") + first_seen = Column("integer") + confirmed_at = Column("integer") + + __unique_1__ = UniqueConstraint("coin_type", "txid") + __index_pending_coin__ = Index("idx_pending_coin", "coin_type", "confirmed_at") diff --git a/basicswap/interface/btc.py b/basicswap/interface/btc.py index 029613c..14ec7e9 100644 --- a/basicswap/interface/btc.py +++ b/basicswap/interface/btc.py @@ -14,6 +14,7 @@ import mmap import os import shutil import sqlite3 +import threading import traceback from io import BytesIO @@ -183,6 +184,7 @@ def extractScriptLockRefundScriptValues(script_bytes: bytes): class BTCInterface(Secp256k1Interface): + _scantxoutset_lock = threading.Lock() @staticmethod def coin_type(): @@ -304,6 +306,46 @@ class BTCInterface(Secp256k1Interface): # Use hardened account indices to match existing wallet keys, only applies when use_descriptors is True self._use_legacy_key_paths = coin_settings.get("use_legacy_key_paths", False) self._disable_lock_tx_rbf = False + self._wallet_manager = None + self._backend = None + self._pending_utxos_map: Dict[str, list] = {} + self._pending_utxos_lock = threading.Lock() + + def setBackend(self, backend) -> None: + self._backend = backend + self._log.debug(f"{self.coin_name()} using backend: {type(backend).__name__}") + + def getBackend(self): + return self._backend + + def useBackend(self) -> bool: + return self._connection_type == "electrum" and self._backend is not None + + def _getTxInputsKey(self, tx) -> str: + if not tx.vin: + return "" + first_in = tx.vin[0] + return f"{i2h(first_in.prevout.hash)}:{first_in.prevout.n}:{len(tx.vin)}" + + def _getPendingUtxos(self, tx) -> Optional[list]: + tx_key = self._getTxInputsKey(tx) + with self._pending_utxos_lock: + return self._pending_utxos_map.get(tx_key) + + def _clearPendingUtxos(self, tx) -> None: + tx_key = self._getTxInputsKey(tx) + with self._pending_utxos_lock: + self._pending_utxos_map.pop(tx_key, None) + + def getWalletManager(self): + if self._wallet_manager is not None: + return self._wallet_manager + if self._sc and hasattr(self._sc, "getWalletManager"): + wm = self._sc.getWalletManager() + if wm and wm.isInitialized(self.coin_type()): + self._wallet_manager = wm + return wm + return None def open_rpc(self, wallet=None): return openrpc(self._rpcport, self._rpcauth, wallet=wallet, host=self._rpc_host) @@ -323,6 +365,12 @@ class BTCInterface(Secp256k1Interface): rpc_conn.close() def checkWallets(self) -> int: + if self._connection_type == "electrum": + wm = self.getWalletManager() + if wm and wm.isInitialized(self.coin_type()): + return 1 + return 0 + wallets = self.rpc("listwallets") if self._rpc_wallet not in wallets: @@ -369,30 +417,83 @@ class BTCInterface(Secp256k1Interface): return len(wallets) def testDaemonRPC(self, with_wallet=True) -> None: + if self._connection_type == "electrum": + if self.useBackend(): + self._backend.getBlockHeight() + return + raise ValueError(f"No electrum backend available for {self.coin_name()}") self.rpc_wallet("getwalletinfo" if with_wallet else "getblockchaininfo") def getDaemonVersion(self): if self._core_version is None: - self._core_version = self.rpc("getnetworkinfo")["version"] + if self.useBackend(): + try: + self._core_version = self._backend.getServerVersion() + except Exception: + self._core_version = "electrum" + else: + self._core_version = self.rpc("getnetworkinfo")["version"] return self._core_version + def getElectrumServer(self) -> str: + if self.useBackend() and hasattr(self._backend, "getServerHost"): + return self._backend.getServerHost() + return None + def getBlockchainInfo(self): + if self.useBackend(): + height = self._backend.getBlockHeight() + return {"blocks": height, "verificationprogress": 1.0} return self.rpc("getblockchaininfo") def getChainHeight(self) -> int: + if self.useBackend(): + self._log.debug("getChainHeight: using backend getBlockHeight") + height = self._backend.getBlockHeight() + self._log.debug(f"getChainHeight: got height={height}") + return height return self.rpc("getblockcount") def getMempoolTx(self, txid): + if self._connection_type == "electrum": + backend = self.getBackend() + if backend: + tx_info = backend.getTransaction(txid.hex()) + if tx_info: + return tx_info.get("hex") if isinstance(tx_info, dict) else tx_info + tx_hex = backend.getTransactionRaw(txid.hex()) + if tx_hex: + return tx_hex + return None return self.rpc("getrawtransaction", [txid.hex()]) def getBlockHeaderFromHeight(self, height): + if self._connection_type == "electrum": + return self._getBlockHeaderFromHeightElectrum(height) block_hash = self.rpc("getblockhash", [height]) return self.rpc("getblockheader", [block_hash]) + def _getBlockHeaderFromHeightElectrum(self, height): + backend = self.getBackend() + if not backend: + raise ValueError("No electrum backend available") + + import struct + + header_hex = backend._server.call("blockchain.block.header", [height]) + header_bytes = bytes.fromhex(header_hex) + block_time = struct.unpack(" str: # Use a bip44 style path, however the seed (derived from the particl mnemonic keychain) can't be turned into a bip39 mnemonic without the matching entropy @@ -419,6 +520,21 @@ class BTCInterface(Secp256k1Interface): def initialiseWallet(self, key_bytes: bytes, restore_time: int = -1) -> None: assert len(key_bytes) == 32 self._have_checked_seed = False + + if self._connection_type == "electrum": + self._log.info(f"Initialising {self.coin_name()} wallet in electrum mode") + wm = self.getWalletManager() + if wm: + wm.initialize(self.coin_type(), key_bytes) + self._log.info( + f"{self.coin_name()} WalletManager initialized successfully" + ) + else: + self._log.warning( + f"No WalletManager available for {self.coin_name()} electrum mode" + ) + return + if self._use_descriptors: self._log.info("Importing descriptors") ek = ExtKeyPair() @@ -516,13 +632,118 @@ class BTCInterface(Secp256k1Interface): return rv def getWalletInfo(self): + if self.useBackend(): + cached = getattr(self, "_cached_wallet_info", None) + if cached is not None: + return cached + + db_balance = 0 + wm = self.getWalletManager() + if wm: + try: + db_balance = wm.getCachedTotalBalance(self.coin_type()) + except Exception: + pass + + return { + "balance": db_balance / self.COIN() if db_balance else 0, + "unconfirmed_balance": 0, + "immature_balance": 0, + "encrypted": True, + "locked": False, + "locked_utxos": 0, + "syncing": True, + } + rv = self.rpc_wallet("getwalletinfo") rv["encrypted"] = "unlocked_until" in rv rv["locked"] = rv.get("unlocked_until", 1) <= 0 rv["locked_utxos"] = len(self.rpc_wallet("listlockunspent")) return rv + def _queryElectrumWalletInfo(self, funded_only: bool = False): + total_confirmed_sats = 0 + total_unconfirmed_sats = 0 + wm = self.getWalletManager() + if wm: + addresses = wm.getAllAddresses(self.coin_type(), funded_only=funded_only) + + if addresses: + try: + detailed_balances = self._backend.getDetailedBalance(addresses) + for addr, bal_info in detailed_balances.items(): + confirmed = bal_info.get("confirmed", 0) + unconfirmed = bal_info.get("unconfirmed", 0) + total_confirmed_sats += confirmed + total_unconfirmed_sats += unconfirmed + except Exception as e: + self._log.warning(f"_queryElectrumWalletInfo error: {e}") + + balance_btc = total_confirmed_sats / self.COIN() + unconfirmed_btc = total_unconfirmed_sats / self.COIN() + + pending_outgoing = 0 + pending_incoming = 0 + pending_count = 0 + if wm: + pending_txs = wm.getPendingTxs(self.coin_type()) + for ptx in pending_txs: + pending_count += 1 + if ptx.get("tx_type") == "outgoing": + pending_outgoing += ptx.get("amount", 0) + elif ptx.get("tx_type") == "incoming": + pending_incoming += ptx.get("amount", 0) + + result = { + "balance": balance_btc, + "unconfirmed_balance": unconfirmed_btc, + "immature_balance": 0, + "encrypted": True, + "locked": False, + "locked_utxos": 0, + "pending_outgoing": pending_outgoing / self.COIN(), + "pending_incoming": pending_incoming / self.COIN(), + "pending_tx_count": pending_count, + } + + self._cached_wallet_info = result + return result + + def refreshElectrumWalletInfo(self, full_scan: bool = False): + if not self.useBackend(): + return + + do_full_scan = full_scan + if not do_full_scan: + scan_counter = getattr(self, "_electrum_scan_counter", 0) + self._electrum_scan_counter = scan_counter + 1 + do_full_scan = scan_counter % 6 == 0 + + try: + if hasattr(self._backend, "setBackgroundMode"): + self._backend.setBackgroundMode(True) + try: + self._queryElectrumWalletInfo(funded_only=not do_full_scan) + + wm = self.getWalletManager() + if wm and self._backend: + wm.syncBalances( + self.coin_type(), self._backend, funded_only=not do_full_scan + ) + finally: + if hasattr(self._backend, "setBackgroundMode"): + self._backend.setBackgroundMode(False) + except Exception as e: + self._log.debug(f"refreshElectrumWalletInfo error: {e}") + def getWalletRestoreHeight(self) -> int: + if self.useBackend(): + height = self.getChainHeight() + self._log.debug( + f"getWalletRestoreHeight: electrum mode, using current height {height}" + ) + return height + if self._use_descriptors: descriptor = self.getActiveDescriptor() if descriptor is None: @@ -535,7 +756,7 @@ class BTCInterface(Secp256k1Interface): blockchaininfo = self.getBlockchainInfo() best_block = blockchaininfo["bestblockhash"] - chain_synced = round(blockchaininfo["verificationprogress"], 3) + chain_synced = round(blockchaininfo["verificationprogress"], 1) if chain_synced < 1.0: raise ValueError(f"{self.coin_name()} chain isn't synced.") @@ -569,6 +790,14 @@ class BTCInterface(Secp256k1Interface): return None def getWalletSeedID(self) -> str: + if self.useBackend(): + wm = self.getWalletManager() + if wm: + seed_id = wm.getSeedID(self.coin_type()) + if seed_id: + return seed_id + return "Not found" + if self._use_descriptors: descriptor = self.getActiveDescriptor() if descriptor is None: @@ -598,12 +827,27 @@ class BTCInterface(Secp256k1Interface): return expect_seedid == wallet_seed_id def getNewAddress(self, use_segwit: bool, label: str = "swap_receive") -> str: + if self._connection_type == "electrum": + wm = self.getWalletManager() + if wm: + return wm.getNewAddress(self.coin_type(), internal=False, label=label) + raise ValueError( + f"{self.coin_name()} wallet not initialized (electrum mode)" + ) + args = [label] if use_segwit: args.append("bech32") return self.rpc_wallet("getnewaddress", args) def isValidAddress(self, address: str) -> bool: + if self._connection_type == "electrum": + try: + self.decodeAddress(address) + return True + except Exception: + return False + try: rv = self.rpc_wallet("validateaddress", [address]) if rv["isvalid"] is True: @@ -613,14 +857,38 @@ class BTCInterface(Secp256k1Interface): return False def isAddressMine(self, address: str, or_watch_only: bool = False) -> bool: - addr_info = self.rpc_wallet("getaddressinfo", [address]) - if not or_watch_only: - return addr_info["ismine"] + if self._connection_type == "electrum": + wm = self.getWalletManager() + if wm: + info = wm.getAddressInfo(self.coin_type(), address) + if info: + if or_watch_only: + return True + return True + return False - if self._use_descriptors: - addr_info = self.rpc_wallet_watch("getaddressinfo", [address]) + try: + addr_info = self.rpc_wallet("getaddressinfo", [address]) + if not or_watch_only: + if addr_info["ismine"]: + return True + else: + if self._use_descriptors: + addr_info = self.rpc_wallet_watch("getaddressinfo", [address]) + if addr_info["ismine"] or addr_info["iswatchonly"]: + return True + except Exception as e: + self._log.debug(f"isAddressMine RPC check failed: {e}") - return addr_info["ismine"] or addr_info["iswatchonly"] + wm = self.getWalletManager() + if wm: + info = wm.getAddressInfo(self.coin_type(), address) + if info: + if or_watch_only: + return True + return True + + return False def checkAddressMine(self, address: str) -> None: addr_info = self.rpc_wallet("getaddressinfo", [address]) @@ -644,6 +912,17 @@ class BTCInterface(Secp256k1Interface): min_relay_fee = chain_client_settings.get("min_relay_fee", None) def try_get_fee_rate(self, conf_target): + + if self.useBackend(): + try: + fee_sat_vb = self._backend.estimateFee(conf_target) + if fee_sat_vb and fee_sat_vb > 0: + fee_rate = (fee_sat_vb * 1000) / 1e8 + return fee_rate, "electrum" + except Exception as e: + self._log.debug(f"Electrum estimateFee failed: {e}") + return 0.00001, "electrum_default" + try: fee_rate: float = self.rpc_wallet("estimatesmartfee", [conf_target])[ "feerate" @@ -726,6 +1005,30 @@ class BTCInterface(Secp256k1Interface): def encodeScriptDest(self, script: bytes) -> str: return self.encode_p2wsh(script) + def getDestForAddress(self, address: str) -> bytes: + bech32_prefix = self.chainparams_network()["hrp"] + if address.startswith(bech32_prefix + "1"): + _, witprog = segwit_addr.decode(bech32_prefix, address) + return CScript([OP_0, bytes(witprog)]) + + addr_data = decodeAddress(address) + prefix_byte = addr_data[0] + addr_hash = addr_data[1:] + + script_address = self.chainparams_network().get("script_address") + script_address2 = self.chainparams_network().get("script_address2") + + if prefix_byte == script_address or ( + script_address2 is not None and prefix_byte == script_address2 + ): + return CScript([OP_HASH160, addr_hash, OP_EQUAL]) + else: + return CScript([OP_DUP, OP_HASH160, addr_hash, OP_EQUALVERIFY, OP_CHECKSIG]) + + def addressToScripthash(self, address: str) -> str: + script = self.getDestForAddress(address) + return sha256(script)[::-1].hex() + def encode_p2sh(self, script: bytes) -> str: return pubkeyToAddress(self.chainparams_network()["script_address"], script) @@ -1434,6 +1737,9 @@ class BTCInterface(Secp256k1Interface): return pubkey.verify(sig[:-1], sig_hash, hasher=None) # Pop the hashtype byte def fundTx(self, tx: bytes, feerate) -> bytes: + if self.useBackend(): + return self._fundTxElectrum(tx, feerate) + feerate_str = self.format_amount(feerate) # TODO: Unlock unspents if bid cancelled # TODO: Manually select only segwit prevouts @@ -1445,6 +1751,150 @@ class BTCInterface(Secp256k1Interface): tx_bytes: bytes = bytes.fromhex(rv["hex"]) return tx_bytes + def _fundTxElectrum(self, tx: bytes, feerate) -> bytes: + wm = self.getWalletManager() + backend = self.getBackend() + if not wm or not backend: + raise ValueError("Electrum backend or WalletManager not available") + + parsed_tx = self.loadTx(tx, allow_witness=False) + total_output = sum(out.nValue for out in parsed_tx.vout) + + funded_addresses = wm.getFundedAddresses(self.coin_type()) + addr_to_sh = ( + funded_addresses + if funded_addresses + else wm.getSignableAddresses(self.coin_type()) + ) + + if not addr_to_sh: + raise ValueError("No addresses available") + + scripthashes = list(addr_to_sh.values()) + sh_to_addr = {sh: addr for addr, sh in addr_to_sh.items()} + + batch_utxos = backend.getBatchUnspent(scripthashes) + + utxos = [] + locked_count = 0 + for sh, sh_utxos in batch_utxos.items(): + addr = sh_to_addr.get(sh, "") + if not addr: + self._log.warning(f"_fundTxElectrum: no address for scripthash {sh}") + for utxo in sh_utxos: + utxo["address"] = addr + if addr: + computed_sh = self.addressToScripthash(addr) + if computed_sh != sh: + self._log.error( + f"_fundTxElectrum: scripthash mismatch for {addr}: " + f"stored={sh}, computed={computed_sh}" + ) + if wm.isUTXOLocked( + self.coin_type(), utxo.get("txid", ""), utxo.get("vout", 0) + ): + locked_count += 1 + continue + utxos.append(utxo) + + if not utxos: + if locked_count > 0: + raise ValueError( + f"No UTXOs available ({locked_count} locked for pending swaps)" + ) + raise ValueError("No UTXOs available") + + utxos.sort(key=lambda x: x.get("value", 0), reverse=True) + + input_vsize = 68 + est_vsize = 10 + len(parsed_tx.vout) * 34 + input_vsize + if isinstance(feerate, int): + fee_per_vbyte = max(1, feerate // 1000) + else: + fee_per_vbyte = max(1, int(feerate * 100000)) + est_fee = est_vsize * fee_per_vbyte + + selected_utxos = [] + total_input = 0 + target = total_output + est_fee + + for utxo in utxos: + selected_utxos.append(utxo) + total_input += utxo.get("value", 0) + est_vsize = ( + 10 + len(parsed_tx.vout) * 34 + len(selected_utxos) * input_vsize + 34 + ) + est_fee = est_vsize * fee_per_vbyte + target = total_output + est_fee + if total_input >= target: + break + + if total_input < target: + raise ValueError( + f"Insufficient funds: have {total_input}, need {target} sats" + ) + + funded_tx = CTransaction() + funded_tx.nVersion = self.txVersion() + + for utxo in selected_utxos: + txid_bytes = bytes.fromhex(utxo["txid"])[::-1] + txid_int = int.from_bytes(txid_bytes, "little") + funded_tx.vin.append( + CTxIn(COutPoint(txid_int, utxo["vout"]), nSequence=0xFFFFFFFD) + ) + + for out in parsed_tx.vout: + funded_tx.vout.append(out) + + final_vsize = ( + 10 + (len(funded_tx.vout) + 1) * 34 + len(selected_utxos) * input_vsize + ) + final_fee = final_vsize * fee_per_vbyte + + min_relay_fee = 250 + final_fee = max(final_fee, min_relay_fee) + change = total_input - total_output - final_fee + + if change > 1000: + change_addr = wm.getNewInternalAddress(self.coin_type()) + if not change_addr: + change_addr = wm.getExistingInternalAddress(self.coin_type()) + if not change_addr: + change_addr = selected_utxos[0].get("address") + pkh = self.decodeAddress(change_addr) + change_script = self.getScriptForPubkeyHash(pkh) + funded_tx.vout.append(self.txoType()(change, change_script)) + else: + final_vsize = ( + 10 + len(funded_tx.vout) * 34 + len(selected_utxos) * input_vsize + ) + final_fee = max(final_vsize * fee_per_vbyte, min_relay_fee) + change = 0 # Goes to fees + + for utxo in selected_utxos: + wm.lockUTXO( + self.coin_type(), + utxo.get("txid", ""), + utxo.get("vout", 0), + value=utxo.get("value", 0), + address=utxo.get("address"), + expires_in=3600, + ) + + tx_serialized = funded_tx.serialize() + tx_key = self._getTxInputsKey(funded_tx) + with self._pending_utxos_lock: + self._pending_utxos_map[tx_key] = selected_utxos + + self._log.debug( + f"_fundTxElectrum: outputs={len(parsed_tx.vout)}, utxos={len(utxos)}, " + f"selected={len(selected_utxos)}, input={total_input}, output={total_output}, " + f"fee={final_fee}, change={change}" + ) + + return tx_serialized + def getNonSegwitOutputs(self): unspents = self.rpc_wallet("listunspent", [0, 99999999]) nonsegwit_unspents = [] @@ -1474,7 +1924,9 @@ class BTCInterface(Secp256k1Interface): return nonsegwit_unspents def lockNonSegwitPrevouts(self) -> None: - # For tests + if self.useBackend(): + return + to_lock = self.getNonSegwitOutputs() if len(to_lock) > 0: @@ -1484,6 +1936,18 @@ class BTCInterface(Secp256k1Interface): def listInputs(self, tx_bytes: bytes): tx = self.loadTx(tx_bytes) + if self.useBackend(): + inputs = [] + for pi in tx.vin: + inputs.append( + { + "txid": i2h(pi.prevout.hash), + "vout": pi.prevout.n, + "islocked": False, + } + ) + return inputs + all_locked = self.rpc_wallet("listlockunspent") inputs = [] for pi in tx.vin: @@ -1500,6 +1964,9 @@ class BTCInterface(Secp256k1Interface): return inputs def unlockInputs(self, tx_bytes): + if self.useBackend(): + return + tx = self.loadTx(tx_bytes) inputs = [] @@ -1508,10 +1975,164 @@ class BTCInterface(Secp256k1Interface): self.rpc_wallet("lockunspent", [True, inputs]) def signTxWithWallet(self, tx: bytes) -> bytes: + if self.useBackend(): + return self._signTxWithWalletElectrum(tx) + rv = self.rpc_wallet("signrawtransactionwithwallet", [tx.hex()]) return bytes.fromhex(rv["hex"]) - def signTxWithKey(self, tx: bytes, key: bytes) -> bytes: + def _signTxWithWalletElectrum(self, tx: bytes) -> bytes: + from coincurve import PrivateKey + + wm = self.getWalletManager() + backend = self.getBackend() + if not wm or not backend: + raise ValueError("Electrum backend or WalletManager not available") + + parsed_tx = self.loadTx(tx) + + utxos = self._getPendingUtxos(parsed_tx) + fetched_from_backend = False + if not utxos or len(utxos) != len(parsed_tx.vin): + fetched_from_backend = True + utxos = [] + txids_to_fetch = [i2h(vin.prevout.hash) for vin in parsed_tx.vin] + + tx_batch = {} + if hasattr(backend, "getTransactionBatch"): + tx_batch = backend.getTransactionBatch(txids_to_fetch) + + needs_raw = any( + tx_batch.get(t) is None or not isinstance(tx_batch.get(t), dict) + for t in txids_to_fetch + ) + + if needs_raw: + if hasattr(backend, "getTransactionBatchRaw"): + tx_batch_raw = backend.getTransactionBatchRaw(txids_to_fetch) + else: + tx_batch_raw = { + t: backend.getTransactionRaw(t) for t in txids_to_fetch + } + + for vin in parsed_tx.vin: + txid_hex = i2h(vin.prevout.hash) + vout_n = vin.prevout.n + prev_tx_hex = tx_batch_raw.get(txid_hex) + if prev_tx_hex: + prev_tx = self.loadTx(bytes.fromhex(prev_tx_hex)) + if vout_n < len(prev_tx.vout): + prev_out = prev_tx.vout[vout_n] + addr = self.getAddressFromScriptPubKey( + prev_out.scriptPubKey + ) + utxos.append( + { + "address": addr, + "value": prev_out.nValue, + "txid": txid_hex, + "vout": vout_n, + } + ) + else: + for vin in parsed_tx.vin: + txid_hex = i2h(vin.prevout.hash) + vout = vin.prevout.n + prev_tx = tx_batch.get(txid_hex) + if prev_tx and "vout" in prev_tx: + vouts = prev_tx["vout"] + if vout >= len(vouts): + self._log.warning( + f"_signTxWithWalletElectrum: vout {vout} out of range for {txid_hex[:16]}..." + ) + continue + prev_out = vouts[vout] + if "scriptPubKey" in prev_out: + addr = prev_out["scriptPubKey"].get( + "address", + prev_out["scriptPubKey"].get("addresses", [None])[0], + ) + value = int(prev_out.get("value", 0) * 100000000) + utxos.append( + { + "address": addr, + "value": value, + "txid": txid_hex, + "vout": vout, + } + ) + + for i, (vin, utxo) in enumerate(zip(parsed_tx.vin, utxos)): + address = utxo.get("address") + if not address: + raise ValueError(f"Cannot find address for input {i}") + + priv_key = wm.getPrivateKey(self.coin_type(), address) + if not priv_key: + if wm.importAddress(self.coin_type(), address, max_scan_index=2000): + priv_key = wm.getPrivateKey(self.coin_type(), address) + if not priv_key: + scripthash = self.addressToScripthash(address) + found_addr = wm.findAddressByScripthash(self.coin_type(), scripthash) + if found_addr: + self._log.debug( + f"_signTxWithWalletElectrum: found address by scripthash: " + f"{address[:10]}... -> {found_addr[:10]}..." + ) + priv_key = wm.getPrivateKey(self.coin_type(), found_addr) + if not priv_key: + addr_info = wm.getAddressInfo(self.coin_type(), address) + if addr_info and addr_info.get("is_watch_only"): + self._log.error( + f"_signTxWithWalletElectrum: Address {address} is watch-only without private key. " + f"This UTXO cannot be spent. The funds may have been received from an external source " + f"or the wallet was not properly initialized when the address was created. " + f"label={addr_info.get('label', 'unknown')}" + ) + else: + self._log.error( + f"_signTxWithWalletElectrum: Cannot find private key for address {address}, " + f"txid={utxo.get('txid', 'unknown')[:16]}..., vout={utxo.get('vout', -1)}" + ) + raise ValueError(f"Cannot find private key for address {address}") + + pk = PrivateKey(priv_key) + pubkey = pk.public_key.format() + + expected_pkh = self.decodeAddress(address) + actual_pkh = hash160(pubkey) + if expected_pkh != actual_pkh: + self._log.error( + f"Private key mismatch for address {address}: " + f"expected pkh {expected_pkh.hex()}, got {actual_pkh.hex()}" + ) + raise ValueError(f"Private key does not match address {address}") + + script_code = CScript( + [OP_DUP, OP_HASH160, expected_pkh, OP_EQUALVERIFY, OP_CHECKSIG] + ) + value = utxo.get("value", 0) + + sig = self.signTx(priv_key, tx, i, script_code, value) + + parsed_tx.wit.vtxinwit.append(CTxInWitness()) + parsed_tx.wit.vtxinwit[i].scriptWitness.stack = [sig, pubkey] + + self._log.debug( + f"_signTxWithWalletElectrum: signed {len(utxos)} inputs " + f"(fetched={'yes' if fetched_from_backend else 'no'})" + ) + + self._clearPendingUtxos(parsed_tx) + + return parsed_tx.serialize() + + def signTxWithKey( + self, tx: bytes, key: bytes, prev_amount: Optional[int] = None + ) -> bytes: + if self.useBackend(): + return self._signTxWithKeyLocal(tx, key, prev_amount) + key_wif = self.encodeKey(key) rv = self.rpc( "signrawtransactionwithkey", @@ -1524,9 +2145,167 @@ class BTCInterface(Secp256k1Interface): ) return bytes.fromhex(rv["hex"]) + def _signTxWithKeyLocal( + self, tx: bytes, key: bytes, prev_amount: Optional[int] = None + ) -> bytes: + from coincurve import PrivateKey + + if prev_amount is None: + raise ValueError( + "_signTxWithKeyLocal requires prev_amount for signature hash" + ) + + pk = PrivateKey(key) + pubkey = pk.public_key.format() + pkh = hash160(pubkey) + + script_code = CScript([OP_DUP, OP_HASH160, pkh, OP_EQUALVERIFY, OP_CHECKSIG]) + + sig = self.signTx(key, tx, 0, script_code, prev_amount) + + parsed_tx = self.loadTx(tx) + parsed_tx.wit.vtxinwit.clear() + parsed_tx.wit.vtxinwit.append(CTxInWitness()) + parsed_tx.wit.vtxinwit[0].scriptWitness.stack = [sig, pubkey] + + self._log.debug( + f"_signTxWithKeyLocal: signed tx with key, prev_amount={prev_amount}" + ) + + return parsed_tx.serialize() + def publishTx(self, tx: bytes): + if self.useBackend(): + txid = self._backend.broadcastTransaction(tx.hex()) + wm = self.getWalletManager() + if wm and txid: + parsed_tx = self.loadTx(tx) + total_out = sum(out.nValue for out in parsed_tx.vout) + wm.addPendingTx( + self.coin_type(), + txid, + tx_type="outgoing", + amount=total_out, + ) + return txid return self.rpc("sendrawtransaction", [tx.hex()]) + def bumpTxFee(self, txid: str, new_feerate: float) -> Optional[str]: + if not self.useBackend(): + try: + result = self.rpc_wallet("bumpfee", [txid, {"fee_rate": new_feerate}]) + return result.get("txid") + except Exception as e: + self._log.warning(f"bumpfee failed: {e}") + return None + + backend = self.getBackend() + wm = self.getWalletManager() + if not backend or not wm: + return None + + try: + tx_info = backend.getTransaction(txid) + tx_hex = None + if tx_info and isinstance(tx_info, dict): + tx_hex = tx_info.get("hex") + if not tx_hex: + tx_hex = backend.getTransactionRaw(txid) + if not tx_hex: + self._log.warning(f"bumpTxFee: Cannot find tx {txid}") + return None + + orig_tx = self.loadTx(bytes.fromhex(tx_hex)) + + rbf_enabled = any(vin.nSequence < 0xFFFFFFFE for vin in orig_tx.vin) + if not rbf_enabled: + self._log.warning(f"bumpTxFee: Transaction {txid} is not RBF-enabled") + return None + + total_in = 0 + prev_txids = [i2h(vin.prevout.hash) for vin in orig_tx.vin] + if hasattr(backend, "getTransactionBatchRaw"): + tx_batch_raw = backend.getTransactionBatchRaw(prev_txids) + else: + tx_batch_raw = {t: backend.getTransactionRaw(t) for t in prev_txids} + + for vin in orig_tx.vin: + prev_txid = i2h(vin.prevout.hash) + prev_tx_hex = tx_batch_raw.get(prev_txid) + if prev_tx_hex: + prev_tx = self.loadTx(bytes.fromhex(prev_tx_hex)) + if vin.prevout.n < len(prev_tx.vout): + total_in += prev_tx.vout[vin.prevout.n].nValue + + total_out = sum(out.nValue for out in orig_tx.vout) + current_fee = total_in - total_out + + # Calculate new fee + tx_vsize = self.getTxVSize(orig_tx) + new_fee = int(tx_vsize * new_feerate) + + if new_fee <= current_fee: + self._log.warning( + f"bumpTxFee: New fee {new_fee} must be higher than current {current_fee}" + ) + return None + + fee_increase = new_fee - current_fee + + change_idx = None + change_value = 0 + for idx, out in enumerate(orig_tx.vout): + try: + addr = self.getAddressFromScriptPubKey(out.scriptPubKey) + if wm.hasAddress(self.coin_type(), addr): + if out.nValue > change_value: + change_idx = idx + change_value = out.nValue + except Exception: + continue + + if change_idx is None or change_value < fee_increase: + self._log.warning("bumpTxFee: No suitable change output to reduce") + return None + + new_tx = CTransaction() + new_tx.nVersion = orig_tx.nVersion + new_tx.vin = orig_tx.vin + new_tx.vout = [] + + for idx, out in enumerate(orig_tx.vout): + if idx == change_idx: + new_out = self.txoType()( + out.nValue - fee_increase, out.scriptPubKey + ) + new_tx.vout.append(new_out) + else: + new_tx.vout.append(out) + + signed_tx = self.signTxWithWallet(new_tx.serialize()) + new_txid = self.publishTx(signed_tx) + + self._log.info( + f"bumpTxFee: Replaced {txid[:16]}... with {new_txid[:16]}... " + f"(fee: {current_fee} -> {new_fee})" + ) + return new_txid + + except Exception as e: + self._log.warning(f"bumpTxFee failed: {e}") + return None + + def getAddressFromScriptPubKey(self, script) -> Optional[str]: + """Extract address from scriptPubKey.""" + script_bytes = bytes(script) if hasattr(script, "__bytes__") else script + if len(script_bytes) == 22 and script_bytes[0] == 0 and script_bytes[1] == 20: + pkh = script_bytes[2:22] + return self.encodeSegwitAddress(pkh) + if len(script_bytes) == 25 and script_bytes[0:3] == b"\x76\xa9\x14": + pkh = script_bytes[3:23] + return self.pkh_to_address(pkh) + return None + def encodeTx(self, tx) -> bytes: return tx.serialize() @@ -1581,22 +2360,122 @@ class BTCInterface(Secp256k1Interface): return self.getScriptForPubkeyHash(self.getPubkeyHash(K)) def scanTxOutset(self, dest): + if self._connection_type == "electrum": + return self._scanTxOutsetElectrum(dest) return self.rpc("scantxoutset", ["start", ["raw({})".format(dest.hex())]]) + def _scanTxOutsetElectrum(self, dest): + backend = self.getBackend() + if not backend: + return {"success": False, "unspents": [], "total_amount": 0} + + scripthash = self.scriptToScripthash(dest) + try: + utxos = backend._server.call( + "blockchain.scripthash.listunspent", [scripthash] + ) + chain_height = backend.getBlockHeight() + total = sum(u.get("value", 0) for u in utxos) + return { + "success": True, + "height": chain_height, + "unspents": [ + { + "txid": u["tx_hash"], + "vout": u["tx_pos"], + "amount": u["value"] / self.COIN(), + "height": u.get("height", 0), + } + for u in utxos + ], + "total_amount": total / self.COIN(), + } + except Exception as e: + self._log.debug(f"_scanTxOutsetElectrum error: {e}") + return {"success": False, "unspents": [], "total_amount": 0} + def getTransaction(self, txid: bytes): + if self._connection_type == "electrum": + return self._getTransactionElectrum(txid) try: return bytes.fromhex(self.rpc("getrawtransaction", [txid.hex()])) except Exception as e: # noqa: F841 # TODO: filter errors return None + def _getTransactionElectrum(self, txid: bytes): + backend = self.getBackend() + if not backend: + return None + try: + tx_info = backend.getTransaction(txid.hex()) + if tx_info: + tx_hex = tx_info.get("hex") if isinstance(tx_info, dict) else tx_info + return bytes.fromhex(tx_hex) + tx_hex = backend.getTransactionRaw(txid.hex()) + if tx_hex: + return bytes.fromhex(tx_hex) + except Exception as e: + self._log.debug(f"_getTransactionElectrum failed for {txid.hex()}: {e}") + return None + def getWalletTransaction(self, txid: bytes): + if self._connection_type == "electrum": + return self._getTransactionElectrum(txid) try: return bytes.fromhex(self.rpc_wallet("gettransaction", [txid.hex()])["hex"]) except Exception as e: # noqa: F841 # TODO: filter errors return None + def listWalletTransactions(self, count=100, skip=0, include_watchonly=True): + if self._connection_type == "electrum": + return self._listWalletTransactionsElectrum(count, skip) + try: + return self.rpc_wallet( + "listtransactions", ["*", count, skip, include_watchonly] + ) + except Exception as e: + self._log.error(f"listWalletTransactions failed: {e}") + return [] + + def _listWalletTransactionsElectrum(self, count=100, skip=0): + backend = self.getBackend() + if not backend: + return [] + + transactions = [] + chain_height = backend.getBlockHeight() + + addresses = [] + if hasattr(self, "_wallet_manager") and self._wallet_manager: + addresses = list(self._wallet_manager._addresses.values()) + + for address in addresses: + try: + history = backend.getAddressHistory(address) + for tx in history: + tx_hash = tx.get("txid", tx.get("tx_hash", "")) + height = tx.get("height", 0) + confirmations = ( + max(0, chain_height - height + 1) if height > 0 else 0 + ) + transactions.append( + { + "txid": tx_hash, + "address": address, + "confirmations": confirmations, + "category": "receive", + "amount": 0, + "time": 0, + } + ) + except Exception as e: + self._log.debug(f"listWalletTransactions electrum error for tx: {e}") + + transactions = transactions[skip : skip + count] + return transactions + def setTxSignature(self, tx_bytes: bytes, stack) -> bytes: tx = self.loadTx(tx_bytes) tx.wit.vtxinwit.clear() @@ -1714,14 +2593,36 @@ class BTCInterface(Secp256k1Interface): script_pk = self.getPkDest(Kbs) if locked_n is None: - wtx = self.rpc_wallet_watch( - "gettransaction", - [ - chain_b_lock_txid.hex(), - ], - ) - lock_tx = self.loadTx(bytes.fromhex(wtx["hex"])) - locked_n = findOutput(lock_tx, script_pk) + if self.useBackend(): + backend = self.getBackend() + tx_hex = backend.getTransactionRaw(chain_b_lock_txid.hex()) + if tx_hex: + lock_tx = self.loadTx(bytes.fromhex(tx_hex)) + locked_n = findOutput(lock_tx, script_pk) + if locked_n is None: + self._log.error( + f"spendBLockTx: Output not found in tx {chain_b_lock_txid.hex()}, " + f"script_pk={script_pk.hex()}, num_outputs={len(lock_tx.vout)}" + ) + for i, out in enumerate(lock_tx.vout): + self._log.debug( + f" vout[{i}]: value={out.nValue}, scriptPubKey={out.scriptPubKey.hex()}" + ) + else: + self._log.warning( + f"spendBLockTx: Failed to fetch tx {chain_b_lock_txid.hex()} from electrum, " + f"defaulting to vout=0 (standard for B lock transactions)" + ) + locked_n = 0 + else: + wtx = self.rpc_wallet_watch( + "gettransaction", + [ + chain_b_lock_txid.hex(), + ], + ) + lock_tx = self.loadTx(bytes.fromhex(wtx["hex"])) + locked_n = findOutput(lock_tx, script_pk) ensure(locked_n is not None, "Output not found in tx") pkh_to = self.decodeAddress(address_to) @@ -1747,11 +2648,21 @@ class BTCInterface(Secp256k1Interface): tx.vout[0].nValue = cb_swap_value - pay_fee b_lock_spend_tx = tx.serialize() - b_lock_spend_tx = self.signTxWithKey(b_lock_spend_tx, kbs) + b_lock_spend_tx = self.signTxWithKey( + b_lock_spend_tx, kbs, prev_amount=cb_swap_value + ) return bytes.fromhex(self.publishTx(b_lock_spend_tx)) def importWatchOnlyAddress(self, address: str, label: str) -> None: + if self._connection_type == "electrum": + wm = self.getWalletManager() + if wm: + wm.importWatchOnlyAddress( + self.coin_type(), address, label=label, source="swap" + ) + return + if self._use_descriptors: desc_watch = descsum_create(f"addr({address})") rv = self.rpc_wallet_watch( @@ -1784,6 +2695,11 @@ class BTCInterface(Secp256k1Interface): find_index: bool = False, vout: int = -1, ): + if self._connection_type == "electrum": + return self._getLockTxHeightElectrum( + txid, dest_address, bid_amount, rescan_from, find_index, vout + ) + # Add watchonly address and rescan if required if not self.isAddressMine(dest_address, or_watch_only=True): self.importWatchOnlyAddress(dest_address, "bid") @@ -1851,7 +2767,211 @@ class BTCInterface(Secp256k1Interface): return rv + def _getLockTxHeightElectrum( + self, + txid, + dest_address, + bid_amount, + rescan_from, + find_index: bool = False, + vout: int = -1, + ): + backend = self.getBackend() + if not backend: + self._log.error("No electrum backend available for getLockTxHeight") + return None + + self.importWatchOnlyAddress(dest_address, "bid") + + chain_height = self.getChainHeight() + return_txid = txid is None + + if txid is None: + utxos = backend.getUnspentOutputs([dest_address]) + for utxo in utxos: + if utxo.get("value") == bid_amount: + txid = bytes.fromhex(utxo["txid"]) + break + + if txid is None: + return None + + wm = self.getWalletManager() + if wm: + cached = wm.getCachedTxConfirmations(self.coin_type(), txid.hex()) + if cached is not None: + confirmations, block_height = cached + if block_height > 0: + confirmations = max(0, chain_height - block_height + 1) + rv = {"depth": confirmations, "height": block_height} + if find_index: + try: + tx_info = backend.getTransaction(txid.hex()) + tx_hex = None + if tx_info and isinstance(tx_info, dict): + tx_hex = tx_info.get("hex") + if not tx_hex: + tx_hex = backend.getTransactionRaw(txid.hex()) + if tx_hex: + tx = self.loadTx(bytes.fromhex(tx_hex)) + dest_script = self.getDestForAddress(dest_address) + for idx, txout in enumerate(tx.vout): + if txout.scriptPubKey == dest_script: + rv["index"] = idx + break + except Exception: + pass + if return_txid: + rv["txid"] = txid.hex() + return rv + + try: + tx_info = backend.getTransaction(txid.hex()) + block_height = 0 + confirmations = 0 + + if tx_info and isinstance(tx_info, dict): + if "height" in tx_info: + block_height = tx_info.get("height", 0) + elif "confirmations" in tx_info: + confirmations = tx_info.get("confirmations", 0) + if confirmations > 0: + block_height = chain_height - confirmations + 1 + + if block_height > 0: + confirmations = max(0, chain_height - block_height + 1) + + if block_height == 0: + history = backend.getAddressHistory(dest_address) + for entry in history: + if entry.get("txid") == txid.hex(): + block_height = entry.get("height", 0) + if block_height > 0: + confirmations = max(0, chain_height - block_height + 1) + break + + if wm: + wm.cacheTxConfirmations( + self.coin_type(), txid.hex(), confirmations, block_height + ) + + rv = { + "depth": confirmations, + "height": block_height if block_height > 0 else 0, + } + except Exception as e: + self._log.debug( + "getLockTxHeight electrum failed: %s, %s", txid.hex(), str(e) + ) + return None + + if find_index: + try: + tx_info = backend.getTransaction(txid.hex()) + tx_hex = None + if tx_info and isinstance(tx_info, dict): + tx_hex = tx_info.get("hex") + if not tx_hex: + tx_hex = backend.getTransactionRaw(txid.hex()) + if tx_hex: + tx = self.loadTx(bytes.fromhex(tx_hex)) + dest_script = self.getDestForAddress(dest_address) + for idx, txout in enumerate(tx.vout): + if txout.scriptPubKey == dest_script: + rv["index"] = idx + break + except Exception as e: + self._log.debug( + f"lookupUnspentByAddress electrum index lookup error: {e}" + ) + + if return_txid: + rv["txid"] = txid.hex() + + return rv + + def scriptToScripthash(self, script: bytes) -> str: + return sha256(script)[::-1].hex() + + def checkWatchedOutput(self, txid_hex: str, vout: int): + backend = self.getBackend() + if not backend: + return None + + try: + tx_hex = backend._server.call_background( + "blockchain.transaction.get", [txid_hex, False] + ) + if not tx_hex: + return None + + tx = self.loadTx(bytes.fromhex(tx_hex)) + script_hex = tx.vout[vout].scriptPubKey.hex() + scripthash = self.scriptToScripthash(bytes.fromhex(script_hex)) + + history = backend._server.call_background( + "blockchain.scripthash.get_history", [scripthash] + ) + self._log.debug( + f"checkWatchedOutput {txid_hex}:{vout} - history has {len(history)} entries" + ) + + for tx_entry in history: + self._log.debug( + f" history entry: {tx_entry.get('tx_hash')[:16]}... height={tx_entry.get('height', 0)}" + ) + if tx_entry.get("tx_hash") != txid_hex: + spend_hex = backend._server.call_background( + "blockchain.transaction.get", [tx_entry["tx_hash"], False] + ) + if not spend_hex: + continue + spend_tx = self.loadTx(bytes.fromhex(spend_hex)) + for i, inp in enumerate(spend_tx.vin): + inp_txid = f"{inp.prevout.hash:064x}" + if inp_txid == txid_hex and inp.prevout.n == vout: + self._log.debug(f" Found spend in {tx_entry['tx_hash']}") + return { + "txid": tx_entry["tx_hash"], + "vin": i, + "height": tx_entry.get("height", 0), + } + except Exception as e: + self._log.debug(f"checkWatchedOutput exception for {txid_hex}:{vout}: {e}") + return None + + def checkWatchedScript(self, script: bytes): + backend = self.getBackend() + if not backend: + return None + + try: + scripthash = self.scriptToScripthash(script) + history = backend._server.call_background( + "blockchain.scripthash.get_history", [scripthash] + ) + for tx_entry in history: + tx_hex = backend._server.call_background( + "blockchain.transaction.get", [tx_entry["tx_hash"], False] + ) + if not tx_hex: + continue + tx = self.loadTx(bytes.fromhex(tx_hex)) + for i, out in enumerate(tx.vout): + if out.scriptPubKey == script: + return { + "txid": tx_entry["tx_hash"], + "vout": i, + "height": tx_entry.get("height", 0), + } + except Exception as e: + self._log.debug(f"_findOutputSpendingScript electrum error: {e}") + return None + def getOutput(self, txid, dest_script, expect_value, xmr_swap=None): + if self._connection_type == "electrum": + return self._getOutputElectrum(txid, dest_script, expect_value, xmr_swap) + # TODO: Use getrawtransaction if txindex is active utxos = self.rpc( "scantxoutset", ["start", ["raw({})".format(dest_script.hex())]] @@ -1883,10 +3003,63 @@ class BTCInterface(Secp256k1Interface): ) return rv, chain_height + def _getOutputElectrum(self, txid, dest_script, expect_value, xmr_swap=None): + backend = self.getBackend() + if not backend: + return [], 0 + + scripthash = self.scriptToScripthash(dest_script) + chain_height = backend.getBlockHeight() + rv = [] + + try: + utxos = backend._server.call( + "blockchain.scripthash.listunspent", [scripthash] + ) + for utxo in utxos: + utxo_txid = utxo["tx_hash"] + if txid and txid.hex() != utxo_txid: + continue + + utxo_value = utxo["value"] + if expect_value != utxo_value: + continue + + utxo_height = utxo.get("height", 0) + rv.append( + { + "depth": ( + 0 if utxo_height <= 0 else (chain_height - utxo_height) + 1 + ), + "height": utxo_height if utxo_height > 0 else 0, + "amount": utxo_value, + "txid": utxo_txid, + "vout": utxo["tx_pos"], + } + ) + except Exception as e: + self._log.debug(f"_getOutputElectrum error: {e}") + + return rv, chain_height + def withdrawCoin(self, value: float, addr_to: str, subfee: bool): + if self.useBackend(): + return self._withdrawCoinElectrum(value, addr_to, subfee) + params = [addr_to, value, "", "", subfee, True, self._conf_target] return self.rpc_wallet("sendtoaddress", params) + def _withdrawCoinElectrum(self, value: float, addr_to: str, subfee: bool) -> str: + + amount_sats = self.make_int(value) + + tx_hex = self._createRawFundedTransactionElectrum(addr_to, amount_sats, subfee) + + signed_tx = self.signTxWithWallet(bytes.fromhex(tx_hex)) + + txid = self._backend.broadcastTransaction(signed_tx.hex()) + return txid + def signCompact(self, k, message: str) -> bytes: message_hash = sha256(bytes(message, "utf-8")) @@ -1953,9 +3126,87 @@ class BTCInterface(Secp256k1Interface): return length def describeTx(self, tx_hex: str): + if self.useBackend(): + return self._describeTxLocal(tx_hex) + return self.rpc("decoderawtransaction", [tx_hex]) + + def _describeTxLocal(self, tx_hex: str) -> dict: + tx = self.loadTx(bytes.fromhex(tx_hex)) + tx.rehash() + + bech32_prefix = self.chainparams_network()["hrp"] + + vout = [] + for i, out in enumerate(tx.vout): + script_hex = out.scriptPubKey.hex() + scriptPubKey = {"hex": script_hex} + + try: + if ( + len(out.scriptPubKey) == 22 + and out.scriptPubKey[0] == 0 + and out.scriptPubKey[1] == 20 + ): + pkh = bytes(out.scriptPubKey[2:22]) + addr = segwit_addr.encode(bech32_prefix, 0, pkh) + scriptPubKey["address"] = addr + elif ( + len(out.scriptPubKey) == 34 + and out.scriptPubKey[0] == 0 + and out.scriptPubKey[1] == 32 + ): + script_hash = bytes(out.scriptPubKey[2:34]) + addr = segwit_addr.encode(bech32_prefix, 0, script_hash) + scriptPubKey["address"] = addr + except Exception as e: + self._log.debug( + f"decodeTransaction address decode error for output {i}: {e}" + ) + + vout.append( + { + "n": i, + "value": self.format_amount(out.nValue), + "scriptPubKey": scriptPubKey, + } + ) + + vin = [] + for inp in tx.vin: + vin.append( + { + "txid": i2h(inp.prevout.hash), + "vout": inp.prevout.n, + "sequence": inp.nSequence, + } + ) + + txid = ( + tx.hash + if hasattr(tx, "hash") and tx.hash + else (i2h(tx.sha256) if tx.sha256 else "") + ) + + return { + "txid": txid, + "version": tx.nVersion, + "locktime": tx.nLockTime, + "vin": vin, + "vout": vout, + } + + def decodeRawTransaction(self, tx_hex: str): + if self.useBackend(): + return self._describeTxLocal(tx_hex) return self.rpc("decoderawtransaction", [tx_hex]) def getSpendableBalance(self) -> int: + if self.useBackend(): + cached = getattr(self, "_cached_wallet_info", None) + if cached is not None: + return self.make_int(cached.get("balance", 0)) + return 0 + return self.make_int(self.rpc_wallet("getbalances")["mine"]["trusted"]) def createUTXO(self, value_sats: int): @@ -1978,6 +3229,9 @@ class BTCInterface(Secp256k1Interface): sub_fee: bool = False, lock_unspents: bool = True, ) -> str: + if self.useBackend(): + return self._createRawFundedTransactionElectrum(addr_to, amount, sub_fee) + txn = self.rpc( "createrawtransaction", [[], {addr_to: self.format_amount(amount)}] ) @@ -1992,18 +3246,152 @@ class BTCInterface(Secp256k1Interface): ] return self.rpc_wallet("fundrawtransaction", [txn, options])["hex"] + def _createRawFundedTransactionElectrum( + self, addr_to: str, amount: int, sub_fee: bool = False + ) -> str: + feerate, _rate_src = self.get_fee_rate() + if isinstance(feerate, int): + fee_per_vbyte = max(1, feerate // 1000) + else: + fee_per_vbyte = max(1, int(feerate * 100000)) + + if sub_fee: + wm = self.getWalletManager() + backend = self.getBackend() + if not wm or not backend: + raise ValueError("Electrum backend or WalletManager not available") + + funded_addresses = wm.getFundedAddresses(self.coin_type()) + addr_to_sh = ( + funded_addresses + if funded_addresses + else wm.getSignableAddresses(self.coin_type()) + ) + + scripthashes = list(addr_to_sh.values()) + sh_to_addr = {sh: addr for addr, sh in addr_to_sh.items()} + batch_utxos = backend.getBatchUnspent(scripthashes) + + all_utxos = [] + for sh, sh_utxos in batch_utxos.items(): + addr = sh_to_addr.get(sh, "") + for utxo in sh_utxos: + utxo["address"] = addr + all_utxos.append(utxo) + + if not all_utxos: + raise ValueError("No UTXOs available") + + total_balance = sum(u.get("value", 0) for u in all_utxos) + + est_vsize = 10 + 31 + len(all_utxos) * 68 + est_fee = est_vsize * fee_per_vbyte + + if total_balance <= est_fee: + raise ValueError( + f"Balance {total_balance} too small to cover fee {est_fee}" + ) + + tx = CTransaction() + tx.nVersion = self.txVersion() + + for utxo in all_utxos: + txid_bytes = bytes.fromhex(utxo["txid"])[::-1] + txid_int = int.from_bytes(txid_bytes, "little") + txin = CTxIn(COutPoint(txid_int, utxo["vout"])) + txin.nSequence = 0xFFFFFFFD + tx.vin.append(txin) + + script = self.getDestForAddress(addr_to) + tx.vout.append(self.txoType()(total_balance - est_fee, script)) + + tx_key = self._getTxInputsKey(tx) + with self._pending_utxos_lock: + self._pending_utxos_map[tx_key] = all_utxos + + self._log.debug( + f"_createRawFundedTransactionElectrum: sub_fee=True, utxos={len(all_utxos)}, " + f"balance={total_balance}, fee={est_fee}" + ) + return tx.serialize().hex() + + tx = CTransaction() + tx.nVersion = self.txVersion() + script = self.getDestForAddress(addr_to) + output = self.txoType()(amount, script) + tx.vout.append(output) + + tx_bytes = tx.serialize() + funded_tx = self.fundTx(tx_bytes, feerate) + + return funded_tx.hex() + def createRawSignedTransaction(self, addr_to, amount) -> str: txn_funded = self.createRawFundedTransaction(addr_to, amount) + + if self.useBackend(): + signed = self.signTxWithWallet(bytes.fromhex(txn_funded)) + return signed.hex() + return self.rpc_wallet("signrawtransactionwithwallet", [txn_funded])["hex"] def getBlockWithTxns(self, block_hash: str): + if self._connection_type == "electrum": + raise NotImplementedError("getBlockWithTxns not available in electrum mode") return self.rpc("getblock", [block_hash, 2]) def listUtxos(self): + if self._connection_type == "electrum": + return self._listUtxosElectrum() return self.rpc_wallet("listunspent") + def _listUtxosElectrum(self): + backend = self.getBackend() + if not backend: + return [] + + utxos = [] + addresses = [] + if hasattr(self, "_wallet_manager") and self._wallet_manager: + addresses = list(self._wallet_manager._addresses.values()) + + chain_height = backend.getBlockHeight() + for address in addresses: + try: + scripthash = self.encodeScriptHash(self.decodeAddress(address)) + addr_utxos = backend._server.call( + "blockchain.scripthash.listunspent", [scripthash] + ) + for u in addr_utxos: + height = u.get("height", 0) + confirmations = ( + max(0, chain_height - height + 1) if height > 0 else 0 + ) + utxos.append( + { + "txid": u["tx_hash"], + "vout": u["tx_pos"], + "address": address, + "amount": u["value"] / self.COIN(), + "confirmations": confirmations, + "spendable": True, + } + ) + except Exception as e: + self._log.debug(f"getUnspentOutputs electrum error for address: {e}") + return utxos + def getUnspentsByAddr(self): unspent_addr = dict() + + if self.useBackend(): + wm = self.getWalletManager() + if wm: + addresses = wm.getAllAddresses(self.coin_type()) + if addresses: + return self._backend.getBalance(addresses) + return unspent_addr + unspent = self.rpc_wallet("listunspent") for u in unspent: if u.get("spendable", False) is False: @@ -2028,27 +3416,143 @@ class BTCInterface(Secp256k1Interface): return unspent_addr def getUTXOBalance(self, address: str): + if self._connection_type == "electrum": + return self._getUTXOBalanceElectrum(address) + sum_unspent = 0 - self._log.debug("[rm] scantxoutset start") # scantxoutset is slow - ro = self.rpc( - "scantxoutset", ["start", ["addr({})".format(address)]] - ) # TODO: Use combo(address) where possible - self._log.debug("[rm] scantxoutset end") - for o in ro["unspents"]: - sum_unspent += self.make_int(o["amount"]) + + with BTCInterface._scantxoutset_lock: + self._log.debug("scantxoutset start") + ro = self.rpc("scantxoutset", ["start", ["addr({})".format(address)]]) + self._log.debug("scantxoutset end") + + for o in ro["unspents"]: + sum_unspent += self.make_int(o["amount"]) return sum_unspent + def _getUTXOBalanceElectrum(self, address: str): + backend = self.getBackend() + if not backend: + return 0 + + try: + scripthash = self.encodeScriptHash(self.decodeAddress(address)) + utxos = backend._server.call( + "blockchain.scripthash.listunspent", [scripthash] + ) + return sum(u.get("value", 0) for u in utxos) + except Exception as e: + self._log.debug(f"_getUTXOBalanceElectrum error: {e}") + return 0 + def signMessage(self, address: str, message: str) -> str: + if self._connection_type == "electrum": + return self._signMessageElectrum(address, message) return self.rpc_wallet( "signmessage", [address, message], ) + def _signMessageElectrum(self, address: str, message: str) -> str: + wm = self.getWalletManager() + if not wm: + raise ValueError("WalletManager not available") + + privkey = wm.getPrivateKey(self.coin_type(), address) + if not privkey: + raise ValueError(f"Private key not found for address: {address}") + + key_wif = self.encodeKey(privkey) + return self._signMessageWithKeyLocal(key_wif, message) + def signMessageWithKey(self, key_wif: str, message: str) -> str: + if self._connection_type == "electrum": + return self._signMessageWithKeyLocal(key_wif, message) return self.rpc("signmessagewithprivkey", [key_wif, message]) + def _signMessageWithKeyLocal(self, key_wif: str, message: str) -> str: + from coincurve import PrivateKey as CCPrivateKey + + privkey_bytes = decodeWif(key_wif) + + message_magic = self.chainparams()["message_magic"] + message_bytes = ( + SerialiseNumCompact(len(message_magic)) + + bytes(message_magic, "utf-8") + + SerialiseNumCompact(len(message)) + + bytes(message, "utf-8") + ) + message_hash = sha256(sha256(message_bytes)) + + pk = CCPrivateKey(privkey_bytes) + sig = pk.sign_recoverable(message_hash, hasher=None) + + rec_id = sig[64] + header = 27 + rec_id + 4 + formatted_sig = bytes([header]) + sig[:64] + + return base64.b64encode(formatted_sig).decode("utf-8") + def getProofOfFunds(self, amount_for, extra_commit_bytes): - # TODO: Lock unspent and use same output/s to fund bid + if self.useBackend(): + wm = self.getWalletManager() + if wm: + result = wm.findAddressWithCachedBalance( + self.coin_type(), + amount_for, + include_internal=False, + max_cache_age=120, + ) + + if result is None and not wm.hasCachedBalances(self.coin_type()): + try: + addresses = wm.getAllAddresses( + self.coin_type(), include_internal=False + ) + if addresses: + result = self._backend.findAddressWithBalance( + addresses, amount_for + ) + except Exception as e: + self._log.warning( + f"getProofOfFunds: error querying balance: {e}" + ) + + ensure( + result is not None, + "Could not find address with enough funds for proof", + ) + funds_addr, balance = result + sign_for_addr = funds_addr + + try: + if self.using_segwit(): + pkh = self.decodeAddress(sign_for_addr) + sign_for_addr = self.pkh_to_address(pkh) + + sign_message = ( + sign_for_addr + "_swap_proof_" + extra_commit_bytes.hex() + ) + priv_key = wm.getPrivateKey(self.coin_type(), funds_addr) + if priv_key: + key_wif = self.encodeKey(priv_key) + signature = self.signMessageWithKey(key_wif, sign_message) + self._log.debug( + f"getProofOfFunds electrum: addr={funds_addr[:20]}..., balance={balance}" + ) + return (sign_for_addr, signature, []) + else: + self._log.error( + f"getProofOfFunds electrum: priv_key is None for {funds_addr}" + ) + except Exception as e: + self._log.error(f"getProofOfFunds electrum: signing failed: {e}") + import traceback + + self._log.error(traceback.format_exc()) + raise + raise ValueError("Cannot sign message: address not in WalletManager") + unspent_addr = self.getUnspentsByAddr() sign_for_addr = None for addr, value in unspent_addr.items(): @@ -2148,31 +3652,63 @@ class BTCInterface(Secp256k1Interface): if self.using_segwit(): address = self.encodeSegwitAddress(decodeAddress(address)[1:]) - return self.getUTXOBalance(address) + if self.useBackend(): + backend = self.getBackend() + if backend: + try: + unspents = backend.getUnspentOutputs([address]) + total = sum(u.get("value", 0) for u in unspents) + self._log.debug( + f"verifyProofOfFunds electrum: {address} has {total} sats" + ) + return total + except Exception as e: + self._log.warning( + f"Electrum balance check failed: {e}, skipping balance verification" + ) + return 10**18 + + try: + return self.getUTXOBalance(address) + except Exception as e: + self._log.warning( + f"scantxoutset failed: {e}, skipping balance verification (signature valid)" + ) + return 10**18 def isWalletEncrypted(self) -> bool: + if self._connection_type == "electrum": + return False wallet_info = self.rpc_wallet("getwalletinfo") return "unlocked_until" in wallet_info def isWalletLocked(self) -> bool: + if self._connection_type == "electrum": + return False wallet_info = self.rpc_wallet("getwalletinfo") if "unlocked_until" in wallet_info and wallet_info["unlocked_until"] <= 0: return True return False def isWalletEncryptedLocked(self) -> (bool, bool): + if self._connection_type == "electrum": + return False, False wallet_info = self.rpc_wallet("getwalletinfo") encrypted = "unlocked_until" in wallet_info locked = encrypted and wallet_info["unlocked_until"] <= 0 return encrypted, locked def createWallet(self, wallet_name: str, password: str = "") -> None: + if self._connection_type == "electrum": + return self.rpc( "createwallet", [wallet_name, False, True, password, False, self._use_descriptors], ) def setActiveWallet(self, wallet_name: str) -> None: + if self._connection_type == "electrum": + return # For debugging self.rpc_wallet = make_rpc_func( self._rpcport, self._rpcauth, host=self._rpc_host, wallet=wallet_name @@ -2180,10 +3716,14 @@ class BTCInterface(Secp256k1Interface): self._rpc_wallet = wallet_name def newKeypool(self) -> None: + if self._connection_type == "electrum": + return self._log.debug("Running newkeypool.") self.rpc_wallet("newkeypool") def encryptWallet(self, password: str, check_seed: bool = True): + if self._connection_type == "electrum": + return # Watchonly wallets are not encrypted # Workaround for https://github.com/bitcoin/bitcoin/issues/26607 seed_id_before: str = self.getWalletSeedID() @@ -2401,6 +3941,9 @@ class BTCInterface(Secp256k1Interface): return self._log.info(f"unlockWallet - {self.ticker()}") + if self.useBackend(): + return + if self.coin_type() == Coins.BTC: # Recreate wallet if none found # Required when encrypting an existing btc wallet, workaround is to delete the btc wallet and recreate @@ -2429,6 +3972,8 @@ class BTCInterface(Secp256k1Interface): def lockWallet(self): self._log.info(f"lockWallet - {self.ticker()}") + if self.useBackend(): + return self.rpc_wallet("walletlock") def get_p2sh_script_pubkey(self, script: bytearray) -> bytearray: @@ -2440,6 +3985,9 @@ class BTCInterface(Secp256k1Interface): return CScript([OP_0, sha256(script)]) def findTxnByHash(self, txid_hex: str): + if self._connection_type == "electrum": + return self._findTxnByHashElectrum(txid_hex) + # Only works for wallet txns try: rv = self.rpc_wallet("gettransaction", [txid_hex]) @@ -2452,6 +4000,72 @@ class BTCInterface(Secp256k1Interface): return {"txid": txid_hex, "amount": 0, "height": rv["blockheight"]} return None + def _findTxnByHashElectrum(self, txid_hex: str): + backend = self.getBackend() + if not backend: + return None + + try: + tx_info = backend.getTransaction(txid_hex) + chain_height = backend.getBlockHeight() + block_height = 0 + confirmations = 0 + tx_hex = None + + if tx_info and isinstance(tx_info, dict): + if "height" in tx_info: + block_height = tx_info.get("height", 0) + elif "block_height" in tx_info: + block_height = tx_info.get("block_height", 0) + elif "confirmations" in tx_info: + confirmations = tx_info.get("confirmations", 0) + if confirmations > 0: + block_height = chain_height - confirmations + 1 + tx_hex = tx_info.get("hex") + + if block_height == 0 and not tx_hex: + tx_hex = backend.getTransactionRaw(txid_hex) + if not tx_hex: + return None + + if block_height == 0 and tx_hex: + try: + tx = self.loadTx(bytes.fromhex(tx_hex)) + for txout in tx.vout: + try: + addr = self.encodeScriptDest(txout.scriptPubKey) + if addr: + history = backend.getAddressHistory(addr) + for entry in history: + if ( + entry.get("tx_hash") == txid_hex + or entry.get("txid") == txid_hex + ): + block_height = entry.get("height", 0) + if block_height > 0: + break + if block_height > 0: + break + except Exception: + continue + except Exception as e: + self._log.debug( + f"_findTxnByHashElectrum address fallback failed: {e}" + ) + + if block_height > 0: + confirmations = max(0, chain_height - block_height + 1) + if confirmations >= self.blocks_confirmed: + self._log.debug( + f"_findTxnByHashElectrum found tx {txid_hex[:16]}... " + f"height={block_height}, confirmations={confirmations}" + ) + return {"txid": txid_hex, "amount": 0, "height": block_height} + + except Exception as e: + self._log.debug(f"_findTxnByHashElectrum failed: {e}") + return None + def createRedeemTxn( self, prevout, output_addr: str, output_value: int, txn_script: bytes = None ) -> str: @@ -2459,8 +4073,7 @@ class BTCInterface(Secp256k1Interface): tx.nVersion = self.txVersion() prev_txid = b2i(bytes.fromhex(prevout["txid"])) tx.vin.append(CTxIn(COutPoint(prev_txid, prevout["vout"]))) - pkh = self.decodeAddress(output_addr) - script = self.getScriptForPubkeyHash(pkh) + script = self.getDestForAddress(output_addr) tx.vout.append(self.txoType()(output_value, script)) tx.rehash() return tx.serialize().hex() @@ -2484,8 +4097,7 @@ class BTCInterface(Secp256k1Interface): nSequence=sequence, ) ) - pkh = self.decodeAddress(output_addr) - script = self.getScriptForPubkeyHash(pkh) + script = self.getDestForAddress(output_addr) tx.vout.append(self.txoType()(output_value, script)) tx.rehash() return tx.serialize().hex() @@ -2537,7 +4149,12 @@ class BTCInterface(Secp256k1Interface): return "Transaction already in block chain" in err_str def isTxNonFinalError(self, err_str: str) -> bool: - return "non-BIP68-final" in err_str or "non-final" in err_str + return ( + "non-BIP68-final" in err_str + or "non-final" in err_str + or "Missing inputs" in err_str + or "bad-txns-inputs-missingorspent" in err_str + ) def combine_non_segwit_prevouts(self): self._log.info("Combining non-segwit prevouts") diff --git a/basicswap/interface/dcr/dcr.py b/basicswap/interface/dcr/dcr.py index 5872737..c623465 100644 --- a/basicswap/interface/dcr/dcr.py +++ b/basicswap/interface/dcr/dcr.py @@ -632,6 +632,15 @@ class DCRInterface(Secp256k1Interface): # TODO: filter errors return None + def listWalletTransactions(self, count=100, skip=0, include_watchonly=True): + try: + return self.rpc_wallet( + "listtransactions", ["*", count, skip, include_watchonly] + ) + except Exception as e: + self._log.error(f"listWalletTransactions failed: {e}") + return [] + def getProofOfFunds(self, amount_for, extra_commit_bytes): # TODO: Lock unspent and use same output/s to fund bid diff --git a/basicswap/interface/electrumx.py b/basicswap/interface/electrumx.py new file mode 100644 index 0000000..0950437 --- /dev/null +++ b/basicswap/interface/electrumx.py @@ -0,0 +1,1131 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024-2026 The Basicswap developers +# Distributed under the MIT software license, see the accompanying +# file LICENSE or http://www.opensource.org/licenses/mit-license.php. + +import hashlib +import json +import queue +import socket +import ssl +import threading +import time + +from basicswap.util import TemporaryError + + +def _close_socket_safe(sock): + if sock: + try: + sock.shutdown(socket.SHUT_RDWR) + except Exception: + pass + try: + sock.close() + except Exception: + pass + + +DEFAULT_ELECTRUM_SERVERS = { + "bitcoin": [ + {"host": "electrum.blockstream.info", "port": 50002, "ssl": True}, + {"host": "electrum.emzy.de", "port": 50002, "ssl": True}, + {"host": "electrum.bitaroo.net", "port": 50002, "ssl": True}, + {"host": "electrum.acinq.co", "port": 50002, "ssl": True}, + {"host": "btc.lastingcoin.net", "port": 50002, "ssl": True}, + ], + "litecoin": [ + {"host": "electrum-ltc.bysh.me", "port": 50002, "ssl": True}, + {"host": "electrum.ltc.xurious.com", "port": 50002, "ssl": True}, + {"host": "backup.electrum-ltc.org", "port": 443, "ssl": True}, + {"host": "ltc.rentonisk.com", "port": 50002, "ssl": True}, + {"host": "electrum-ltc.petrkr.net", "port": 60002, "ssl": True}, + {"host": "electrum.jochen-hoenicke.de", "port": 50004, "ssl": True}, + ], +} + +DEFAULT_ONION_SERVERS = { + "bitcoin": [], + "litecoin": [], +} + + +class ElectrumConnection: + def __init__( + self, + host, + port, + use_ssl=True, + timeout=10, + log=None, + proxy_host=None, + proxy_port=None, + ): + self._host = host + self._port = port + self._use_ssl = use_ssl + self._timeout = timeout + self._socket = None + self._request_id = 0 + self._lock = threading.Lock() + self._connected = False + self._response_queues = {} + self._notification_callbacks = {} + self._header_callback = None + self._listener_thread = None + self._listener_running = False + self._log = log + self._proxy_host = proxy_host + self._proxy_port = proxy_port + + def connect(self): + try: + if self._proxy_host and self._proxy_port: + import socks + + sock = socks.socksocket() + sock.set_proxy( + socks.SOCKS5, self._proxy_host, self._proxy_port, rdns=True + ) + sock.settimeout(self._timeout) + sock.connect((self._host, self._port)) + if self._log: + self._log.debug( + f"Electrum connecting via proxy {self._proxy_host}:{self._proxy_port} to {self._host}:{self._port}" + ) + else: + sock = socket.create_connection( + (self._host, self._port), timeout=self._timeout + ) + if self._use_ssl: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + self._socket = context.wrap_socket(sock, server_hostname=self._host) + else: + self._socket = sock + self._connected = True + except Exception as e: + self._connected = False + raise TemporaryError(f"Failed to connect to {self._host}:{self._port}: {e}") + + def disconnect(self): + self._stop_listener() + sock = self._socket + self._socket = None + self._connected = False + _close_socket_safe(sock) + for q in self._response_queues.values(): + try: + q.put({"error": "Connection closed"}) + except Exception: + pass + self._response_queues.clear() + + def is_connected(self): + return self._connected and self._socket is not None + + def _start_listener(self): + if self._listener_thread is not None and self._listener_thread.is_alive(): + return + self._listener_running = True + self._listener_thread = threading.Thread( + target=self._listener_loop, daemon=True + ) + self._listener_thread.start() + + def _stop_listener(self): + self._listener_running = False + if self._listener_thread is not None: + self._listener_thread.join(timeout=2) + self._listener_thread = None + + def _listener_loop(self): + buffer = b"" + while self._listener_running and self._connected and self._socket: + try: + self._socket.settimeout(1.0) + try: + data = self._socket.recv(4096) + except socket.timeout: + continue + if not data: + break + buffer += data + while b"\n" in buffer: + line, buffer = buffer.split(b"\n", 1) + try: + message = json.loads(line.decode()) + self._handle_message(message) + except json.JSONDecodeError: + if self._log: + self._log.debug(f"Invalid JSON from electrum: {line[:100]}") + except Exception as e: + if self._listener_running and self._log: + self._log.debug(f"Electrum listener error: {e}") + break + + def _handle_message(self, message): + if "id" in message and message["id"] is not None: + request_id = message["id"] + if request_id in self._response_queues: + self._response_queues[request_id].put(message) + elif "method" in message: + self._handle_notification(message) + + def _handle_notification(self, message): + method = message.get("method", "") + params = message.get("params", []) + + if method == "blockchain.scripthash.subscribe" and len(params) >= 2: + scripthash = params[0] + new_status = params[1] + if scripthash in self._notification_callbacks: + try: + callback = self._notification_callbacks[scripthash] + callback(scripthash, new_status) + except Exception as e: + if self._log: + self._log.debug(f"Notification callback error: {e}") + elif method == "blockchain.headers.subscribe" and len(params) >= 1: + header = params[0] + height = header.get("height", 0) + if self._log: + self._log.debug(f"New block header notification: height={height}") + if self._header_callback and height > 0: + try: + self._header_callback(height) + except Exception as e: + if self._log: + self._log.debug(f"Header callback error: {e}") + + def register_notification_callback(self, scripthash, callback): + self._notification_callbacks[scripthash] = callback + + def register_header_callback(self, callback): + """Register callback for header height updates. Callback receives height as argument.""" + self._header_callback = callback + + def _send_request(self, method, params=None): + if params is None: + params = [] + with self._lock: + self._request_id += 1 + request_id = self._request_id + + request = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + } + request_data = json.dumps(request) + "\n" + self._socket.sendall(request_data.encode()) + return request_id + + def _receive_response_sync(self, expected_id, timeout=30): + buffer = b"" + self._socket.settimeout(timeout) + while True: + try: + data = self._socket.recv(4096) + if not data: + raise TemporaryError("Connection closed") + buffer += data + while b"\n" in buffer: + line, buffer = buffer.split(b"\n", 1) + response = json.loads(line.decode()) + if response.get("id") == expected_id: + if "error" in response and response["error"]: + raise Exception(f"Electrum error: {response['error']}") + return response.get("result") + elif "method" in response: + self._handle_notification(response) + except socket.timeout: + raise TemporaryError("Request timed out") + + def _receive_response_async(self, expected_id, timeout=30): + try: + response = self._response_queues[expected_id].get(timeout=timeout) + if "error" in response and response["error"]: + raise Exception(f"Electrum error: {response['error']}") + return response.get("result") + except queue.Empty: + raise TemporaryError("Request timed out") + finally: + self._response_queues.pop(expected_id, None) + + def _receive_response(self, expected_id, timeout=30): + if self._listener_running: + return self._receive_response_async(expected_id, timeout) + return self._receive_response_sync(expected_id, timeout) + + def _receive_batch_responses(self, expected_ids, timeout=30): + if self._listener_running: + return self._receive_batch_responses_async(expected_ids, timeout) + return self._receive_batch_responses_sync(expected_ids, timeout) + + def _receive_batch_responses_sync(self, expected_ids, timeout=30): + buffer = b"" + self._socket.settimeout(timeout) + results = {} + pending_ids = set(expected_ids) + + while pending_ids: + try: + data = self._socket.recv(4096) + if not data: + raise TemporaryError("Connection closed") + buffer += data + while b"\n" in buffer: + line, buffer = buffer.split(b"\n", 1) + response = json.loads(line.decode()) + resp_id = response.get("id") + if resp_id in pending_ids: + if "error" in response and response["error"]: + results[resp_id] = {"error": response["error"]} + else: + results[resp_id] = {"result": response.get("result")} + pending_ids.discard(resp_id) + elif "method" in response: + self._handle_notification(response) + except socket.timeout: + raise TemporaryError( + f"Batch request timed out, {len(pending_ids)} responses pending" + ) + return results + + def _receive_batch_responses_async(self, expected_ids, timeout=30): + results = {} + deadline = time.time() + timeout + for req_id in expected_ids: + remaining = deadline - time.time() + if remaining <= 0: + raise TemporaryError("Batch request timed out") + try: + response = self._response_queues[req_id].get(timeout=remaining) + if "error" in response and response["error"]: + results[req_id] = {"error": response["error"]} + else: + results[req_id] = {"result": response.get("result")} + except queue.Empty: + raise TemporaryError("Batch request timed out") + finally: + self._response_queues.pop(req_id, None) + return results + + def call(self, method, params=None, timeout=10): + if not self.is_connected(): + self.connect() + try: + if self._listener_running: + with self._lock: + self._request_id += 1 + request_id = self._request_id + self._response_queues[request_id] = queue.Queue() + request = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params if params else [], + } + self._socket.sendall((json.dumps(request) + "\n").encode()) + result = self._receive_response_async(request_id, timeout=timeout) + return result + else: + request_id = self._send_request(method, params) + result = self._receive_response_sync(request_id, timeout=timeout) + return result + except (ssl.SSLError, OSError, ConnectionError) as e: + _close_socket_safe(self._socket) + self._connected = False + self._socket = None + raise TemporaryError(f"Connection error: {e}") + + def call_batch(self, requests): + if not self.is_connected(): + self.connect() + try: + request_ids = [] + if self._listener_running: + with self._lock: + for method, params in requests: + self._request_id += 1 + request_id = self._request_id + self._response_queues[request_id] = queue.Queue() + request_ids.append(request_id) + req = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params if params else [], + } + self._socket.sendall((json.dumps(req) + "\n").encode()) + else: + for method, params in requests: + request_id = self._send_request(method, params if params else []) + request_ids.append(request_id) + + responses = self._receive_batch_responses(request_ids) + + results = [] + for req_id in request_ids: + resp = responses.get(req_id, {}) + if "error" in resp: + results.append(None) + else: + results.append(resp.get("result")) + return results + except (ssl.SSLError, OSError, ConnectionError) as e: + _close_socket_safe(self._socket) + self._connected = False + self._socket = None + raise TemporaryError(f"Connection error: {e}") + + def ping(self): + try: + start = time.time() + self.call("server.ping") + return (time.time() - start) * 1000 + except Exception: + return None + + def get_server_version(self): + return self.call("server.version", ["BasicSwap", "1.4"]) + + +def scripthash_from_script(script_bytes): + sha = hashlib.sha256(script_bytes).digest() + return sha[::-1].hex() + + +def scripthash_from_address(address, network_params): + from basicswap.util.address import decodeAddress + from basicswap.contrib.test_framework.script import ( + CScript, + OP_DUP, + OP_HASH160, + OP_EQUALVERIFY, + OP_CHECKSIG, + OP_0, + OP_EQUAL, + ) + + try: + addr_data = decodeAddress(address) + addr_type = addr_data[0] + addr_hash = addr_data[1:] + + if addr_type == network_params.get("pubkey_address"): + script = CScript( + [OP_DUP, OP_HASH160, addr_hash, OP_EQUALVERIFY, OP_CHECKSIG] + ) + elif addr_type == network_params.get("script_address"): + script = CScript([OP_HASH160, addr_hash, OP_EQUAL]) + else: + script = CScript([OP_0, addr_hash]) + + return scripthash_from_script(bytes(script)) + except Exception: + from basicswap.contrib.test_framework.segwit_addr import decode as bech32_decode + + hrp = network_params.get("hrp", "bc") + witver, witprog = bech32_decode(hrp, address) + if witver is not None: + script = CScript([OP_0, bytes(witprog)]) + return scripthash_from_script(bytes(script)) + raise ValueError(f"Unable to decode address: {address}") + + +def _parse_server_string(server_str): + parts = server_str.strip().split(":") + host = parts[0] + port = int(parts[1]) if len(parts) > 1 else 50002 + use_ssl = port != 50001 + return {"host": host, "port": port, "ssl": use_ssl} + + +class ElectrumServer: + def __init__( + self, + coin_name, + clearnet_servers=None, + onion_servers=None, + log=None, + proxy_host=None, + proxy_port=None, + ): + self._coin_name = coin_name + self._log = log + self._connection = None + self._current_server_idx = 0 + self._lock = threading.Lock() + + self._server_version = None + self._current_server_host = None + self._current_server_port = None + + self._proxy_host = proxy_host + self._proxy_port = proxy_port + + self._notification_callbacks = {} + self._subscribed_scripthashes = set() + self._realtime_enabled = False + + self._connection_failures = 0 + self._last_connection_error = None + self._using_default_servers = False + self._all_servers_failed = False + + self._server_scores = {} + + self._server_blacklist = {} + self._rate_limit_backoff = 300 + + self._keepalive_thread = None + self._keepalive_running = False + self._keepalive_interval = 15 + self._last_activity = 0 + + self._min_request_interval = 0.02 + self._last_request_time = 0 + + self._bg_connection = None + self._bg_lock = threading.Lock() + self._bg_last_activity = 0 + + self._subscribed_height = 0 + self._subscribed_height_time = 0 + self._height_callback = None + + use_tor = proxy_host is not None and proxy_port is not None + + user_clearnet = [] + if clearnet_servers: + for srv in clearnet_servers: + if isinstance(srv, str): + user_clearnet.append(_parse_server_string(srv)) + elif isinstance(srv, dict): + user_clearnet.append(srv) + + user_onion = [] + if onion_servers: + for srv in onion_servers: + if isinstance(srv, str): + user_onion.append(_parse_server_string(srv)) + elif isinstance(srv, dict): + user_onion.append(srv) + + final_clearnet = ( + user_clearnet + if user_clearnet + else DEFAULT_ELECTRUM_SERVERS.get(coin_name, []) + ) + final_onion = ( + user_onion if user_onion else DEFAULT_ONION_SERVERS.get(coin_name, []) + ) + + self._using_default_servers = not user_clearnet and not user_onion + + if use_tor: + self._servers = list(final_onion) + list(final_clearnet) + if self._log and final_onion: + self._log.info( + f"ElectrumServer {coin_name}: TOR enabled - " + f"{len(final_onion)} .onion + {len(final_clearnet)} clearnet servers" + ) + else: + self._servers = list(final_clearnet) + if self._log: + self._log.info( + f"ElectrumServer {coin_name}: {len(final_clearnet)} clearnet servers" + ) + + def _get_server(self, index): + if not self._servers: + raise ValueError(f"No Electrum servers configured for {self._coin_name}") + return self._servers[index % len(self._servers)] + + def connect(self): + sorted_servers = self.get_sorted_servers() + for server in sorted_servers: + try: + start_time = time.time() + conn = ElectrumConnection( + server["host"], + server["port"], + server.get("ssl", True), + log=self._log, + proxy_host=self._proxy_host, + proxy_port=self._proxy_port, + ) + conn.connect() + connect_time = (time.time() - start_time) * 1000 + version_info = conn.get_server_version() + if version_info and len(version_info) > 0: + self._server_version = version_info[0] + self._current_server_host = server["host"] + self._current_server_port = server["port"] + self._connection = conn + self._current_server_idx = self._servers.index(server) + self._connection_failures = 0 + self._last_connection_error = None + self._all_servers_failed = False + self._update_server_score(server, success=True, latency_ms=connect_time) + self._last_activity = time.time() + if self._log: + self._log.info( + f"Connected to Electrum server: {server['host']}:{server['port']} " + f"({self._server_version}, {connect_time:.0f}ms)" + ) + if self._realtime_enabled: + self._start_realtime_listener() + self._start_keepalive() + self._connection.register_header_callback(self._on_header_update) + self._subscribe_headers() + return True + except Exception as e: + self._connection_failures += 1 + self._last_connection_error = str(e) + self._update_server_score(server, success=False) + if self._is_rate_limit_error(str(e)): + self._blacklist_server(server, str(e)) + if self._log: + self._log.debug(f"Failed to connect to {server['host']}: {e}") + continue + self._all_servers_failed = True + raise TemporaryError( + f"Failed to connect to any Electrum server for {self._coin_name}" + ) + + def getConnectionStatus(self): + return { + "connected": self._connection is not None + and self._connection.is_connected(), + "failures": self._connection_failures, + "last_error": self._last_connection_error, + "all_failed": self._all_servers_failed, + "using_defaults": self._using_default_servers, + "server_count": len(self._servers) if self._servers else 0, + } + + def get_server_version(self): + return self._server_version + + def get_current_server(self): + return self._current_server_host, self._current_server_port + + def _get_server_key(self, server): + return f"{server['host']}:{server['port']}" + + def _update_server_score(self, server, success: bool, latency_ms: float = None): + key = self._get_server_key(server) + if key not in self._server_scores: + self._server_scores[key] = {"latency": 0, "failures": 0, "successes": 0} + + score = self._server_scores[key] + if success: + score["successes"] += 1 + if latency_ms is not None: + if score["latency"] == 0: + score["latency"] = latency_ms + else: + score["latency"] = score["latency"] * 0.7 + latency_ms * 0.3 + else: + score["failures"] += 1 + + def _get_server_score(self, server) -> float: + key = self._get_server_key(server) + if key not in self._server_scores: + return 1000 + + score = self._server_scores[key] + total = score["successes"] + score["failures"] + if total == 0: + return 1000 + + failure_rate = score["failures"] / total + return score["latency"] + (failure_rate * 5000) + + def get_sorted_servers(self) -> list: + now = time.time() + available_servers = [] + for s in self._servers: + key = self._get_server_key(s) + if key in self._server_blacklist: + if now < self._server_blacklist[key]: + if self._log: + remaining = int(self._server_blacklist[key] - now) + self._log.debug( + f"Skipping blacklisted server {key} ({remaining}s remaining)" + ) + continue + else: + del self._server_blacklist[key] + available_servers.append(s) + + if not available_servers and self._servers: + if self._log: + self._log.warning("All servers blacklisted, clearing blacklist") + self._server_blacklist.clear() + available_servers = list(self._servers) + + return sorted(available_servers, key=lambda s: self._get_server_score(s)) + + def _blacklist_server(self, server, reason: str = ""): + key = self._get_server_key(server) + self._server_blacklist[key] = time.time() + self._rate_limit_backoff + if self._log: + self._log.warning( + f"Blacklisted server {key} for {self._rate_limit_backoff}s: {reason}" + ) + + def _is_rate_limit_error(self, error_msg: str) -> bool: + rate_limit_patterns = [ + "excessive resource usage", + "rate limit", + "too many requests", + "throttled", + "banned", + ] + error_lower = error_msg.lower() + return any(pattern in error_lower for pattern in rate_limit_patterns) + + def _on_header_update(self, height: int): + if height > self._subscribed_height: + self._subscribed_height = height + self._subscribed_height_time = time.time() + if self._log: + self._log.debug(f"Header subscription updated height to {height}") + if self._height_callback: + try: + self._height_callback(height) + except Exception as e: + if self._log: + self._log.debug(f"Height callback error: {e}") + + def _subscribe_headers(self): + try: + if self._connection: + self._connection._start_listener() + result = self._connection.call( + "blockchain.headers.subscribe", [], timeout=10 + ) + if result and isinstance(result, dict): + height = result.get("height", 0) + if height > 0: + self._on_header_update(height) + except Exception as e: + if self._log: + self._log.debug(f"Failed to subscribe to headers: {e}") + + def register_height_callback(self, callback): + self._height_callback = callback + + def get_subscribed_height(self) -> int: + return self._subscribed_height + + def get_server_scores(self) -> dict: + return { + self._get_server_key(s): { + **self._server_scores.get(self._get_server_key(s), {}), + "score": self._get_server_score(s), + } + for s in self._servers + } + + def _start_keepalive(self): + if self._keepalive_running: + return + self._keepalive_running = True + self._keepalive_thread = threading.Thread( + target=self._keepalive_loop, daemon=True + ) + self._keepalive_thread.start() + if self._log: + self._log.debug( + f"Electrum keepalive started for {self._coin_name} " + f"(interval={self._keepalive_interval}s)" + ) + + def _stop_keepalive(self): + self._keepalive_running = False + if self._keepalive_thread: + self._keepalive_thread.join(timeout=2) + self._keepalive_thread = None + + def _keepalive_loop(self): + while self._keepalive_running: + try: + for _ in range(self._keepalive_interval): + if not self._keepalive_running: + return + time.sleep(1) + + if time.time() - self._last_activity >= self._keepalive_interval: + if self._connection and self._connection.is_connected(): + if self._lock.acquire(blocking=False): + try: + self._connection.call("server.ping") + self._last_activity = time.time() + except Exception: + pass + finally: + self._lock.release() + except Exception: + pass + + def _throttle_request(self): + now = time.time() + elapsed = now - self._last_request_time + if elapsed < self._min_request_interval: + time.sleep(self._min_request_interval - elapsed) + self._last_request_time = time.time() + + def _retry_on_failure(self): + self._current_server_idx = (self._current_server_idx + 1) % len(self._servers) + if self._connection: + try: + self._connection.disconnect() + except Exception: + pass + self._connection = None + time.sleep(0.3) + self.connect() + + def _check_connection_health(self, timeout=5) -> bool: + if self._connection is None or not self._connection.is_connected(): + return False + try: + self._connection.call("server.ping", [], timeout=timeout) + return True + except Exception as e: + if self._log: + self._log.debug(f"Connection health check failed: {e}") + return False + + def call(self, method, params=None, timeout=10): + self._throttle_request() + lock_acquired = self._lock.acquire(timeout=timeout + 5) + if not lock_acquired: + raise TemporaryError(f"Electrum call timed out waiting for lock: {method}") + try: + for attempt in range(2): + if self._connection is None or not self._connection.is_connected(): + self.connect() + elif (time.time() - self._last_activity) > 10: + if not self._check_connection_health(): + self._retry_on_failure() + try: + result = self._connection.call(method, params, timeout=timeout) + self._last_activity = time.time() + return result + except Exception as e: + if self._is_rate_limit_error(str(e)): + server = self._get_server(self._current_server_idx) + self._blacklist_server(server, str(e)) + if attempt == 0: + self._retry_on_failure() + else: + raise + finally: + self._lock.release() + + def call_batch(self, requests, timeout=15): + self._throttle_request() + lock_acquired = self._lock.acquire(timeout=timeout + 5) + if not lock_acquired: + raise TemporaryError("Electrum batch call timed out waiting for lock") + try: + for attempt in range(2): + if self._connection is None or not self._connection.is_connected(): + self.connect() + elif (time.time() - self._last_activity) > 10: + if not self._check_connection_health(): + self._retry_on_failure() + try: + result = self._connection.call_batch(requests) + self._last_activity = time.time() + return result + except Exception as e: + if self._is_rate_limit_error(str(e)): + server = self._get_server(self._current_server_idx) + self._blacklist_server(server, str(e)) + if attempt == 0: + self._retry_on_failure() + else: + raise + finally: + self._lock.release() + + def _connect_background(self): + sorted_servers = self.get_sorted_servers() + for server in sorted_servers: + try: + conn = ElectrumConnection( + server["host"], + server["port"], + server.get("ssl", True), + log=self._log, + proxy_host=self._proxy_host, + proxy_port=self._proxy_port, + ) + conn.connect() + self._bg_connection = conn + if self._log: + self._log.debug( + f"Background connection established to {server['host']}" + ) + return True + except Exception as e: + if self._log: + self._log.debug( + f"Background connection failed to {server['host']}: {e}" + ) + continue + return False + + def call_background(self, method, params=None, timeout=10): + lock_acquired = self._bg_lock.acquire(timeout=1) + if not lock_acquired: + return self.call(method, params, timeout) + + try: + if self._bg_connection is None or not self._bg_connection.is_connected(): + if not self._connect_background(): + self._bg_lock.release() + return self.call(method, params, timeout) + + try: + result = self._bg_connection.call(method, params, timeout=timeout) + self._bg_last_activity = time.time() + return result + except Exception: + if self._bg_connection: + try: + self._bg_connection.disconnect() + except Exception: + pass + self._bg_connection = None + + if self._connect_background(): + try: + result = self._bg_connection.call( + method, params, timeout=timeout + ) + self._bg_last_activity = time.time() + return result + except Exception: + pass + + return self.call(method, params, timeout) + finally: + self._bg_lock.release() + + def call_batch_background(self, requests, timeout=15): + lock_acquired = self._bg_lock.acquire(timeout=1) + if not lock_acquired: + return self.call_batch(requests, timeout) + + try: + if self._bg_connection is None or not self._bg_connection.is_connected(): + if not self._connect_background(): + self._bg_lock.release() + return self.call_batch(requests, timeout) + + try: + result = self._bg_connection.call_batch(requests) + self._bg_last_activity = time.time() + return result + except Exception: + if self._bg_connection: + try: + self._bg_connection.disconnect() + except Exception: + pass + self._bg_connection = None + + if self._connect_background(): + try: + result = self._bg_connection.call_batch(requests) + self._bg_last_activity = time.time() + return result + except Exception: + pass + + return self.call_batch(requests, timeout) + finally: + self._bg_lock.release() + + def disconnect(self): + self._stop_keepalive() + with self._lock: + if self._connection: + self._connection.disconnect() + self._connection = None + with self._bg_lock: + if self._bg_connection: + try: + self._bg_connection.disconnect() + except Exception: + pass + self._bg_connection = None + + def get_balance(self, scripthash): + result = self.call("blockchain.scripthash.get_balance", [scripthash]) + return result + + def get_balance_batch(self, scripthashes): + requests = [("blockchain.scripthash.get_balance", [sh]) for sh in scripthashes] + return self.call_batch(requests) + + def get_history(self, scripthash): + return self.call("blockchain.scripthash.get_history", [scripthash]) + + def get_transaction(self, txid, verbose=False): + return self.call("blockchain.transaction.get", [txid, verbose]) + + def estimate_fee(self, num_blocks): + result = self.call("blockchain.estimatefee", [num_blocks]) + return result + + def get_merkle(self, txid, height): + return self.call("blockchain.transaction.get_merkle", [txid, height]) + + def enable_realtime_notifications(self): + self._realtime_enabled = True + if self._connection and self._connection.is_connected(): + self._start_realtime_listener() + if self._log: + self._log.info( + f"Electrum real-time notifications enabled for {self._coin_name}" + ) + + def _start_realtime_listener(self): + if self._connection: + for sh, callback in self._notification_callbacks.items(): + self._connection.register_notification_callback(sh, callback) + self._connection._start_listener() + self._resubscribe_all() + + def _resubscribe_all(self): + for scripthash in list(self._subscribed_scripthashes): + try: + self.call("blockchain.scripthash.subscribe", [scripthash]) + except Exception as e: + if self._log: + self._log.debug( + f"Failed to resubscribe to {scripthash[:16]}...: {e}" + ) + + def subscribe_with_callback(self, scripthash, callback): + self._notification_callbacks[scripthash] = callback + self._subscribed_scripthashes.add(scripthash) + + if self._connection: + self._connection.register_notification_callback(scripthash, callback) + + status = self.call("blockchain.scripthash.subscribe", [scripthash]) + return status + + def discover_peers(self): + try: + peers = self.call("server.peers.subscribe") + if not peers: + return [] + + discovered = [] + for peer in peers: + if not isinstance(peer, list) or len(peer) < 3: + continue + + ip_addr = peer[0] + hostname = peer[1] + features = peer[2] if len(peer) > 2 else [] + + host = hostname if hostname else ip_addr + is_onion = host.endswith(".onion") + + ssl_port = None + tcp_port = None + + for feature in features: + if isinstance(feature, str): + if feature.startswith("s"): + port_str = feature[1:] + ssl_port = int(port_str) if port_str else 50002 + elif feature.startswith("t"): + port_str = feature[1:] + tcp_port = int(port_str) if port_str else 50001 + + if is_onion: + if tcp_port: + discovered.append( + { + "host": host, + "port": tcp_port, + "ssl": False, + "is_onion": True, + } + ) + elif ssl_port: + discovered.append( + { + "host": host, + "port": ssl_port, + "ssl": True, + "is_onion": True, + } + ) + else: + if ssl_port: + discovered.append( + { + "host": host, + "port": ssl_port, + "ssl": True, + "is_onion": False, + } + ) + elif tcp_port: + discovered.append( + { + "host": host, + "port": tcp_port, + "ssl": False, + "is_onion": False, + } + ) + + return discovered + + except Exception as e: + if self._log: + self._log.debug(f"discover_peers failed: {e}") + return [] + + def ping_server(self, host, port, ssl=True, timeout=5): + try: + test_conn = ElectrumConnection( + host, + port, + ssl, + log=self._log, + proxy_host=self._proxy_host, + proxy_port=self._proxy_port, + ) + test_conn.connect() + latency = test_conn.ping() + test_conn.disconnect() + return latency + except Exception: + return None + + def get_current_server_info(self): + return { + "host": self._current_server_host, + "port": self._current_server_port, + "version": self._server_version, + } diff --git a/basicswap/interface/ltc.py b/basicswap/interface/ltc.py index bd0fa96..7ba4d54 100644 --- a/basicswap/interface/ltc.py +++ b/basicswap/interface/ltc.py @@ -27,12 +27,21 @@ class LTCInterface(BTCInterface): ) def getNewMwebAddress(self, use_segwit=False, label="swap_receive") -> str: + if self.useBackend(): + raise ValueError("MWEB addresses not supported in electrum mode") return self.rpc_wallet_mweb("getnewaddress", [label, "mweb"]) def getNewStealthAddress(self, label=""): + if self.useBackend(): + raise ValueError("MWEB addresses not supported in electrum mode") return self.getNewMwebAddress(False, label) def withdrawCoin(self, value, type_from: str, addr_to: str, subfee: bool) -> str: + if self.useBackend(): + if type_from == "mweb": + raise ValueError("MWEB withdrawals not supported in electrum mode") + return self._withdrawCoinElectrum(value, addr_to, subfee) + params = [addr_to, value, "", "", subfee, True, self._conf_target] if type_from == "mweb": return self.rpc_wallet_mweb("sendtoaddress", params) @@ -53,14 +62,27 @@ class LTCInterface(BTCInterface): def getWalletInfo(self): rv = super(LTCInterface, self).getWalletInfo() - mweb_info = self.rpc_wallet_mweb("getwalletinfo") - rv["mweb_balance"] = mweb_info["balance"] - rv["mweb_unconfirmed"] = mweb_info["unconfirmed_balance"] - rv["mweb_immature"] = mweb_info["immature_balance"] + if not self.useBackend(): + try: + mweb_info = self.rpc_wallet_mweb("getwalletinfo") + rv["mweb_balance"] = mweb_info["balance"] + rv["mweb_unconfirmed"] = mweb_info["unconfirmed_balance"] + rv["mweb_immature"] = mweb_info["immature_balance"] + except Exception: + pass return rv def getUnspentsByAddr(self): unspent_addr = dict() + + if self.useBackend(): + wm = self.getWalletManager() + if wm: + addresses = wm.getAllAddresses(self.coin_type()) + if addresses: + return self._backend.getBalance(addresses) + return unspent_addr + unspent = self.rpc_wallet("listunspent") for u in unspent: if u.get("spendable", False) is False: @@ -152,6 +174,9 @@ class LTCInterfaceMWEB(LTCInterface): return self._log.info("unlockWallet - {}".format(self.ticker())) + if self.useBackend(): + return + if not self.has_mweb_wallet(): self.init_wallet(password) else: diff --git a/basicswap/interface/xmr.py b/basicswap/interface/xmr.py index 2a98133..1b34be2 100644 --- a/basicswap/interface/xmr.py +++ b/basicswap/interface/xmr.py @@ -94,6 +94,9 @@ class XMRInterface(CoinInterface): "failed to get output distribution", "request-sent", "idle", + "busy", + "responsenotready", + "connection", ] ): return True @@ -832,3 +835,25 @@ class XMRInterface(CoinInterface): ] }, ) + + def listWalletTransactions(self, count=100, skip=0, include_watchonly=True): + try: + with self._mx_wallet: + self.openWallet(self._wallet_filename) + rv = self.rpc_wallet( + "get_transfers", + {"in": True, "out": True, "pending": True, "failed": True}, + ) + transactions = [] + for tx_type in ["in", "out", "pending", "failed"]: + if tx_type in rv: + for tx in rv[tx_type]: + tx["type"] = tx_type + transactions.append(tx) + transactions.sort(key=lambda x: x.get("timestamp", 0), reverse=True) + return ( + transactions[skip : skip + count] if count else transactions[skip:] + ) + except Exception as e: + self._log.error(f"listWalletTransactions failed: {e}") + return [] diff --git a/basicswap/js_server.py b/basicswap/js_server.py index 1ecd597..5af543a 100644 --- a/basicswap/js_server.py +++ b/basicswap/js_server.py @@ -135,7 +135,7 @@ def js_walletbalances(self, url_split, post_string, is_json) -> bytes: for k, v in swap_client.coin_clients.items(): if k not in chainparams: continue - if v["connection_type"] == "rpc": + if v["connection_type"] in ("rpc", "electrum"): balance = "0.0" if k in wallets: @@ -168,8 +168,20 @@ def js_walletbalances(self, url_split, post_string, is_json) -> bytes: "balance": balance, "pending": pending, "ticker": chainparams[k]["ticker"], + "connection_type": v["connection_type"], } + ci = swap_client.ci(k) + if hasattr(ci, "getScanStatus"): + coin_entry["scan_status"] = ci.getScanStatus() + if hasattr(ci, "getElectrumServer"): + server = ci.getElectrumServer() + if server: + coin_entry["electrum_server"] = server + version = ci.getDaemonVersion() + if version: + coin_entry["version"] = version + coins_with_balances.append(coin_entry) if k == Coins.PART: @@ -293,6 +305,9 @@ def js_wallets(self, url_split, post_string, is_json): elif cmd == "reseed": swap_client.reseedWallet(coin_type) return bytes(json.dumps({"reseeded": True}), "UTF-8") + elif cmd == "rescan": + result = swap_client.rescanWalletAddresses(coin_type) + return bytes(json.dumps(result), "UTF-8") elif cmd == "newstealthaddress": if coin_type != Coins.PART: raise ValueError("Invalid coin for command") @@ -306,6 +321,31 @@ def js_wallets(self, url_split, post_string, is_json): return bytes( json.dumps(swap_client.ci(coin_type).getNewMwebAddress()), "UTF-8" ) + elif cmd == "watchaddress": + post_data = getFormData(post_string, is_json) + address = get_data_entry(post_data, "address") + label = get_data_entry_or(post_data, "label", "manual_import") + wm = swap_client.getWalletManager() + if wm is None: + raise ValueError("WalletManager not available") + wm.importWatchOnlyAddress( + coin_type, address, label=label, source="manual_import" + ) + return bytes(json.dumps({"success": True, "address": address}), "UTF-8") + elif cmd == "listaddresses": + wm = swap_client.getWalletManager() + if wm is None: + raise ValueError("WalletManager not available") + addresses = wm.getAllAddresses(coin_type) + return bytes(json.dumps({"addresses": addresses}), "UTF-8") + elif cmd == "fixseedid": + root_key = swap_client.getWalletKey(coin_type, 1) + swap_client.storeSeedIDForCoin(root_key, coin_type) + swap_client.checkWalletSeed(coin_type) + return bytes( + json.dumps({"success": True, "message": "Seed IDs updated"}), + "UTF-8", + ) raise ValueError("Unknown command") if coin_type == Coins.LTC_MWEB: @@ -1526,6 +1566,71 @@ def js_coinhistory(self, url_split, post_string, is_json) -> bytes: ) +def js_wallettransactions(self, url_split, post_string, is_json) -> bytes: + from basicswap.ui.page_wallet import format_transactions + import time + + TX_CACHE_DURATION = 30 + + swap_client = self.server.swap_client + swap_client.checkSystemStatus() + + if len(url_split) < 4: + return bytes(json.dumps({"error": "No coin specified"}), "UTF-8") + + ticker_str = url_split[3] + coin_id = getCoinIdFromTicker(ticker_str) + + post_data = {} if post_string == "" else getFormData(post_string, is_json) + + page_no = 1 + limit = 30 + offset = 0 + + if have_data_entry(post_data, "page_no"): + page_no = int(get_data_entry(post_data, "page_no")) + if page_no < 1: + page_no = 1 + + if page_no > 1: + offset = (page_no - 1) * limit + + try: + ci = swap_client.ci(coin_id) + + current_time = time.time() + cache_entry = swap_client._tx_cache.get(coin_id) + + if ( + cache_entry is None + or (current_time - cache_entry["time"]) > TX_CACHE_DURATION + ): + all_txs = ci.listWalletTransactions(count=10000, skip=0) + all_txs = list(reversed(all_txs)) if all_txs else [] + swap_client._tx_cache[coin_id] = {"txs": all_txs, "time": current_time} + else: + all_txs = cache_entry["txs"] + + total_transactions = len(all_txs) + raw_txs = all_txs[offset : offset + limit] if all_txs else [] + transactions = format_transactions(ci, raw_txs, coin_id) + + return bytes( + json.dumps( + { + "transactions": transactions, + "page_no": page_no, + "total": total_transactions, + "limit": limit, + "total_pages": (total_transactions + limit - 1) // limit, + } + ), + "UTF-8", + ) + except Exception as e: + return bytes(json.dumps({"error": str(e)}), "UTF-8") + + def js_messageroutes(self, url_split, post_string, is_json) -> bytes: swap_client = self.server.swap_client post_data = {} if post_string == "" else getFormData(post_string, is_json) @@ -1569,10 +1674,107 @@ def js_messageroutes(self, url_split, post_string, is_json) -> bytes: return bytes(json.dumps(message_routes), "UTF-8") +def js_electrum_discover(self, url_split, post_string, is_json) -> bytes: + swap_client = self.server.swap_client + post_data = {} if post_string == "" else getFormData(post_string, is_json) + + coin_str = get_data_entry(post_data, "coin") + do_ping = toBool(get_data_entry_or(post_data, "ping", "false")) + + coin_type = None + try: + coin_id = int(coin_str) + coin_type = Coins(coin_id) + except ValueError: + try: + coin_type = getCoinIdFromName(coin_str) + except ValueError: + coin_type = getCoinType(coin_str) + + electrum_supported = ["bitcoin", "litecoin"] + coin_name = chainparams.get(coin_type, {}).get("name", "").lower() + if coin_name not in electrum_supported: + return bytes( + json.dumps( + {"error": f"Electrum not supported for {coin_name}", "servers": []} + ), + "UTF-8", + ) + + ci = swap_client.ci(coin_type) + connection_type = getattr(ci, "_connection_type", "rpc") + + discovered_servers = [] + current_server = None + + if connection_type == "electrum": + backend = ci.getBackend() + if backend and hasattr(backend, "_server"): + server = backend._server + current_server = server.get_current_server_info() + discovered_servers = server.discover_peers() + + if do_ping and discovered_servers: + for srv in discovered_servers[:10]: + latency = server.ping_server( + srv["host"], srv["port"], srv.get("ssl", True) + ) + srv["latency_ms"] = latency + srv["online"] = latency is not None + else: + try: + from .interface.electrumx import ElectrumServer + + temp_server = ElectrumServer( + coin_name, + log=swap_client.log, + ) + temp_server.connect() + current_server = temp_server.get_current_server_info() + discovered_servers = temp_server.discover_peers() + + if do_ping and discovered_servers: + for srv in discovered_servers[:10]: + latency = temp_server.ping_server( + srv["host"], srv["port"], srv.get("ssl", True) + ) + srv["latency_ms"] = latency + srv["online"] = latency is not None + + temp_server.disconnect() + except Exception as e: + return bytes( + json.dumps( + { + "error": f"Failed to connect to electrum server: {str(e)}", + "servers": [], + } + ), + "UTF-8", + ) + + onion_servers = [s for s in discovered_servers if s.get("is_onion")] + clearnet_servers = [s for s in discovered_servers if not s.get("is_onion")] + + return bytes( + json.dumps( + { + "coin": coin_name, + "current_server": current_server, + "clearnet_servers": clearnet_servers, + "onion_servers": onion_servers, + "total_discovered": len(discovered_servers), + } + ), + "UTF-8", + ) + + endpoints = { "coins": js_coins, "walletbalances": js_walletbalances, "wallets": js_wallets, + "wallettransactions": js_wallettransactions, "offers": js_offers, "sentoffers": js_sentoffers, "bids": js_bids, @@ -1602,6 +1804,7 @@ endpoints = { "coinvolume": js_coinvolume, "coinhistory": js_coinhistory, "messageroutes": js_messageroutes, + "electrumdiscover": js_electrum_discover, } diff --git a/basicswap/static/js/modules/event-handlers.js b/basicswap/static/js/modules/event-handlers.js index 592effe..1f96c8e 100644 --- a/basicswap/static/js/modules/event-handlers.js +++ b/basicswap/static/js/modules/event-handlers.js @@ -2,15 +2,42 @@ 'use strict'; const EventHandlers = { - - confirmPopup: function(action = 'proceed', coinName = '') { - const message = action === 'Accept' - ? 'Are you sure you want to accept this bid?' - : coinName - ? `Are you sure you want to ${action} ${coinName}?` - : 'Are you sure you want to proceed?'; - - return confirm(message); + + showConfirmModal: function(title, message, callback) { + const modal = document.getElementById('confirmModal'); + if (!modal) { + if (callback) callback(); + return; + } + + const titleEl = document.getElementById('confirmTitle'); + const messageEl = document.getElementById('confirmMessage'); + const yesBtn = document.getElementById('confirmYes'); + const noBtn = document.getElementById('confirmNo'); + const bidDetails = document.getElementById('bidDetailsSection'); + + if (titleEl) titleEl.textContent = title; + if (messageEl) { + messageEl.textContent = message; + messageEl.classList.remove('hidden'); + } + if (bidDetails) bidDetails.classList.add('hidden'); + + modal.classList.remove('hidden'); + + const newYesBtn = yesBtn.cloneNode(true); + yesBtn.parentNode.replaceChild(newYesBtn, yesBtn); + + newYesBtn.addEventListener('click', function() { + modal.classList.add('hidden'); + if (callback) callback(); + }); + + const newNoBtn = noBtn.cloneNode(true); + noBtn.parentNode.replaceChild(newNoBtn, noBtn); + newNoBtn.addEventListener('click', function() { + modal.classList.add('hidden'); + }); }, confirmReseed: function() { @@ -18,7 +45,6 @@ }, confirmWithdrawal: function() { - if (window.WalletPage && typeof window.WalletPage.confirmWithdrawal === 'function') { return window.WalletPage.confirmWithdrawal(); } @@ -67,14 +93,36 @@ return; } - const balanceElement = amountInput.closest('form')?.querySelector('[data-balance]'); - const balance = balanceElement ? parseFloat(balanceElement.getAttribute('data-balance')) : 0; + let coinFromId; + if (inputId === 'add-amm-amount') { + coinFromId = 'add-amm-coin-from'; + } else if (inputId === 'edit-amm-amount') { + coinFromId = 'edit-amm-coin-from'; + } else { + const form = amountInput.closest('form') || amountInput.closest('.modal-content') || amountInput.closest('[id*="modal"]'); + const select = form?.querySelector('select[id*="coin-from"]'); + coinFromId = select?.id; + } + + const coinFromSelect = coinFromId ? document.getElementById(coinFromId) : null; + if (!coinFromSelect) { + console.error('EventHandlers: Coin-from dropdown not found for:', inputId); + return; + } + + const selectedOption = coinFromSelect.options[coinFromSelect.selectedIndex]; + if (!selectedOption) { + console.error('EventHandlers: No option selected in coin-from dropdown'); + return; + } + + const balance = parseFloat(selectedOption.getAttribute('data-balance') || '0'); if (balance > 0) { const calculatedAmount = balance * percent; amountInput.value = calculatedAmount.toFixed(8); } else { - console.warn('EventHandlers: No balance found for AMM amount calculation'); + console.warn('EventHandlers: No balance found for selected coin'); } }, @@ -132,13 +180,10 @@ }, hideConfirmModal: function() { - if (window.DOMCache) { - window.DOMCache.hide('confirmModal'); - } else { - const modal = document.getElementById('confirmModal'); - if (modal) { - modal.style.display = 'none'; - } + const modal = document.getElementById('confirmModal'); + if (modal) { + modal.classList.add('hidden'); + modal.style.display = ''; } }, @@ -187,17 +232,43 @@ }, initialize: function() { - + document.addEventListener('click', (e) => { const target = e.target.closest('[data-confirm]'); if (target) { + if (target.dataset.confirmHandled) { + delete target.dataset.confirmHandled; + return; + } + + e.preventDefault(); + e.stopPropagation(); + const action = target.getAttribute('data-confirm-action') || 'proceed'; const coinName = target.getAttribute('data-confirm-coin') || ''; - - if (!this.confirmPopup(action, coinName)) { - e.preventDefault(); - return false; - } + + const message = action === 'Accept' + ? 'Are you sure you want to accept this bid?' + : coinName + ? `Are you sure you want to ${action} ${coinName}?` + : 'Are you sure you want to proceed?'; + + const title = `Confirm ${action}`; + + this.showConfirmModal(title, message, function() { + target.dataset.confirmHandled = 'true'; + + if (target.form) { + const hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + hiddenInput.name = target.name; + hiddenInput.value = target.value; + target.form.appendChild(hiddenInput); + target.form.submit(); + } else { + target.click(); + } + }); } }); @@ -326,8 +397,6 @@ } window.EventHandlers = EventHandlers; - - window.confirmPopup = EventHandlers.confirmPopup.bind(EventHandlers); window.confirmReseed = EventHandlers.confirmReseed.bind(EventHandlers); window.confirmWithdrawal = EventHandlers.confirmWithdrawal.bind(EventHandlers); window.confirmUTXOResize = EventHandlers.confirmUTXOResize.bind(EventHandlers); diff --git a/basicswap/static/js/modules/notification-manager.js b/basicswap/static/js/modules/notification-manager.js index 183568b..91c0a98 100644 --- a/basicswap/static/js/modules/notification-manager.js +++ b/basicswap/static/js/modules/notification-manager.js @@ -250,6 +250,7 @@ function ensureToastContainer() { 'new_bid': 'bg-green-500', 'bid_accepted': 'bg-purple-500', 'swap_completed': 'bg-green-600', + 'sweep_completed': 'bg-orange-500', 'balance_change': 'bg-yellow-500', 'update_available': 'bg-blue-600', 'success': 'bg-blue-500' @@ -735,6 +736,17 @@ function ensureToastContainer() { shouldShowToast = config.showUpdateNotifications; break; + case 'sweep_completed': + const sweepAmount = parseFloat(data.amount || 0).toFixed(8).replace(/\.?0+$/, ''); + const sweepFee = parseFloat(data.fee || 0).toFixed(8).replace(/\.?0+$/, ''); + toastTitle = `Swept ${sweepAmount} ${data.coin_name} to RPC wallet`; + toastOptions.subtitle = `Fee: ${sweepFee} ${data.coin_name} • TXID: ${(data.txid || '').substring(0, 12)}...`; + toastOptions.coinSymbol = data.coin_name; + toastOptions.txid = data.txid; + toastType = 'sweep_completed'; + shouldShowToast = true; + break; + case 'coin_balance_updated': if (data.coin && config.showBalanceChanges) { this.handleBalanceUpdate(data); diff --git a/basicswap/static/js/pages/bid-page.js b/basicswap/static/js/pages/bid-page.js new file mode 100644 index 0000000..106c009 --- /dev/null +++ b/basicswap/static/js/pages/bid-page.js @@ -0,0 +1,459 @@ +const BidPage = { + bidId: null, + bidStateInd: null, + createdAtTimestamp: null, + autoRefreshInterval: null, + elapsedTimeInterval: null, + AUTO_REFRESH_SECONDS: 15, + + INACTIVE_STATES: [8, 17, 18, 19, 21, 22, 23, 25, 31], // Completed, Failed variants, Timed-out, Abandoned, Error, Rejected, Expired + + STATE_TOOLTIPS: { + 'Bid Sent': 'Your bid has been broadcast to the network', + 'Bid Receiving': 'Receiving partial bid message from the network', + 'Bid Received': 'Bid received and waiting for decision to accept or reject', + 'Bid Receiving accept': 'Receiving acceptance message from the other party', + 'Bid Accepted': 'Bid accepted. The atomic swap process is starting', + 'Bid Initiated': 'Swap initiated. First lock transaction is being created', + 'Bid Participating': 'Participating in the swap. Second lock transaction is being created', + 'Bid Completed': 'Swap completed successfully! Both parties received their coins', + 'Bid Script coin locked': 'Your coins are locked in the atomic swap contract on the script chain (e.g., BTC/LTC)', + 'Bid Script coin spend tx valid': 'The spend transaction for the script coin has been validated and is ready', + 'Bid Scriptless coin locked': 'The other party\'s coins are locked using adaptor signatures (e.g., XMR)', + 'Bid Script coin lock released': 'Secret key revealed. The script coin can now be claimed', + 'Bid Script tx redeemed': 'Script coin has been successfully claimed', + 'Bid Script pre-refund tx in chain': 'Pre-refund transaction detected. Swap may be failing', + 'Bid Scriptless tx redeemed': 'Scriptless coin (e.g., XMR) has been successfully claimed', + 'Bid Scriptless tx recovered': 'Scriptless coin recovered after swap failure', + 'Bid Failed, refunded': 'Swap failed but your coins have been refunded', + 'Bid Failed, swiped': 'Swap failed due to an unexpected issue. Please check the event log for details', + 'Bid Failed': 'Swap failed. Check events for details', + 'Bid Delaying': 'Brief delay between swap steps to ensure network propagation', + 'Bid Timed-out': 'Swap timed out waiting for the other party', + 'Bid Abandoned': 'Swap was manually abandoned. Locked coins will be refunded after timelock', + 'Bid Error': 'An error occurred. Check events for details', + 'Bid Rejected': 'Bid was rejected by the offer owner', + 'Bid Stalled (debug)': 'Debug mode: swap intentionally stalled for testing', + 'Bid Exchanged script lock tx sigs msg': 'Exchanging cryptographic signatures needed for lock transactions', + 'Bid Exchanged script lock spend tx msg': 'Exchanging signed spend transaction for locked coins', + 'Bid Request sent': 'Connection request sent to the other party', + 'Bid Request accepted': 'Connection request accepted', + 'Bid Expired': 'Bid expired before being accepted', + 'Bid Auto accept delay': 'Waiting for automation delay before auto-accepting', + 'Bid Auto accept failed': 'Automation failed to accept this bid', + 'Bid Connect request sent': 'Sent connection request to peer', + 'Bid Unknown bid state': 'Unknown state - please check the swap details', + + 'ITX Sent': 'Initiate transaction has been broadcast to the network', + 'ITX Confirmed': 'Initiate transaction has been confirmed by miners', + 'ITX Redeemed': 'Initiate transaction has been successfully claimed', + 'ITX Refunded': 'Initiate transaction has been refunded', + 'ITX In Mempool': 'Initiate transaction is in the mempool (unconfirmed)', + 'ITX In Chain': 'Initiate transaction is included in a block', + 'PTX Sent': 'Participate transaction has been broadcast to the network', + 'PTX Confirmed': 'Participate transaction has been confirmed by miners', + 'PTX Redeemed': 'Participate transaction has been successfully claimed', + 'PTX Refunded': 'Participate transaction has been refunded', + 'PTX In Mempool': 'Participate transaction is in the mempool (unconfirmed)', + 'PTX In Chain': 'Participate transaction is included in a block' + }, + + EVENT_TOOLTIPS: { + 'Lock tx A published': 'First lock transaction broadcast to the blockchain network', + 'Lock tx A seen in mempool': 'First lock transaction detected in mempool (unconfirmed)', + 'Lock tx A seen in chain': 'First lock transaction included in a block', + 'Lock tx A confirmed in chain': 'First lock transaction has enough confirmations', + 'Lock tx B published': 'Second lock transaction broadcast to the blockchain network', + 'Lock tx B seen in mempool': 'Second lock transaction detected in mempool (unconfirmed)', + 'Lock tx B seen in chain': 'Second lock transaction included in a block', + 'Lock tx B confirmed in chain': 'Second lock transaction has enough confirmations', + 'Lock tx A spend tx published': 'Transaction to claim coins from first lock has been broadcast', + 'Lock tx A spend tx seen in chain': 'First lock spend transaction included in a block', + 'Lock tx B spend tx published': 'Transaction to claim coins from second lock has been broadcast', + 'Lock tx B spend tx seen in chain': 'Second lock spend transaction included in a block', + 'Failed to publish lock tx B': 'ERROR: Could not broadcast second lock transaction', + 'Failed to publish lock tx B spend': 'ERROR: Could not broadcast spend transaction for second lock', + 'Failed to publish lock tx B refund': 'ERROR: Could not broadcast refund transaction', + 'Detected invalid lock Tx B': 'ERROR: Second lock transaction is invalid or malformed', + 'Lock tx A pre-refund tx published': 'Pre-refund transaction broadcast. Swap is being cancelled', + 'Lock tx A refund spend tx published': 'Refund transaction for first lock has been broadcast', + 'Lock tx A refund swipe tx published': 'Other party claimed your refund (swiped)', + 'Lock tx B refund tx published': 'Refund transaction for second lock has been broadcast', + 'Lock tx A conflicting txn/s': 'WARNING: Conflicting transaction detected for first lock', + 'Lock tx A pre-refund tx seen in chain': 'Pre-refund transaction detected in blockchain', + 'Lock tx A refund spend tx seen in chain': 'Refund spend transaction detected in blockchain', + 'Initiate tx published': 'Secret-hash swap: Initiate transaction broadcast', + 'Initiate tx redeem tx published': 'Secret-hash swap: Initiate transaction claimed', + 'Initiate tx refund tx published': 'Secret-hash swap: Initiate transaction refunded', + 'Participate tx published': 'Secret-hash swap: Participate transaction broadcast', + 'Participate tx redeem tx published': 'Secret-hash swap: Participate transaction claimed', + 'Participate tx refund tx published': 'Secret-hash swap: Participate transaction refunded', + 'BCH mercy tx found': 'BCH specific: Mercy transaction detected', + 'Lock tx B mercy tx published': 'BCH specific: Mercy transaction broadcast', + 'Auto accepting': 'Automation is accepting this bid', + 'Failed auto accepting': 'Automation constraints prevented accepting this bid', + 'Debug tweak applied': 'Debug mode: A test tweak was applied' + }, + + STATE_PHASES: { + 1: { phase: 'negotiation', order: 1, label: 'Negotiation' }, // BID_SENT + 2: { phase: 'negotiation', order: 2, label: 'Negotiation' }, // BID_RECEIVING + 3: { phase: 'negotiation', order: 3, label: 'Negotiation' }, // BID_RECEIVED + 4: { phase: 'negotiation', order: 4, label: 'Negotiation' }, // BID_RECEIVING_ACC + 5: { phase: 'accepted', order: 5, label: 'Accepted' }, // BID_ACCEPTED + 6: { phase: 'locking', order: 6, label: 'Locking' }, // SWAP_INITIATED + 7: { phase: 'locking', order: 7, label: 'Locking' }, // SWAP_PARTICIPATING + 8: { phase: 'complete', order: 100, label: 'Complete' }, // SWAP_COMPLETED + 9: { phase: 'locking', order: 8, label: 'Locking' }, // XMR_SWAP_SCRIPT_COIN_LOCKED + 10: { phase: 'locking', order: 9, label: 'Locking' }, // XMR_SWAP_HAVE_SCRIPT_COIN_SPEND_TX + 11: { phase: 'locking', order: 10, label: 'Locking' }, // XMR_SWAP_NOSCRIPT_COIN_LOCKED + 12: { phase: 'redemption', order: 11, label: 'Redemption' }, // XMR_SWAP_LOCK_RELEASED + 13: { phase: 'redemption', order: 12, label: 'Redemption' }, // XMR_SWAP_SCRIPT_TX_REDEEMED + 14: { phase: 'failed', order: 90, label: 'Failed' }, // XMR_SWAP_SCRIPT_TX_PREREFUND + 15: { phase: 'redemption', order: 13, label: 'Redemption' }, // XMR_SWAP_NOSCRIPT_TX_REDEEMED + 16: { phase: 'failed', order: 91, label: 'Recovered' }, // XMR_SWAP_NOSCRIPT_TX_RECOVERED + 17: { phase: 'failed', order: 92, label: 'Failed' }, // XMR_SWAP_FAILED_REFUNDED + 18: { phase: 'failed', order: 93, label: 'Failed' }, // XMR_SWAP_FAILED_SWIPED + 19: { phase: 'failed', order: 94, label: 'Failed' }, // XMR_SWAP_FAILED + 20: { phase: 'locking', order: 7.5, label: 'Locking' }, // SWAP_DELAYING + 21: { phase: 'failed', order: 95, label: 'Failed' }, // SWAP_TIMEDOUT + 22: { phase: 'failed', order: 96, label: 'Abandoned' }, // BID_ABANDONED + 23: { phase: 'failed', order: 97, label: 'Error' }, // BID_ERROR + 25: { phase: 'failed', order: 98, label: 'Rejected' }, // BID_REJECTED + 27: { phase: 'accepted', order: 5.5, label: 'Accepted' }, // XMR_SWAP_MSG_SCRIPT_LOCK_TX_SIGS + 28: { phase: 'accepted', order: 5.6, label: 'Accepted' }, // XMR_SWAP_MSG_SCRIPT_LOCK_SPEND_TX + 29: { phase: 'negotiation', order: 0.5, label: 'Negotiation' }, // BID_REQUEST_SENT + 30: { phase: 'negotiation', order: 0.6, label: 'Negotiation' }, // BID_REQUEST_ACCEPTED + 31: { phase: 'failed', order: 99, label: 'Expired' }, // BID_EXPIRED + 32: { phase: 'negotiation', order: 3.5, label: 'Negotiation' }, // BID_AACCEPT_DELAY + 33: { phase: 'failed', order: 89, label: 'Failed' }, // BID_AACCEPT_FAIL + 34: { phase: 'negotiation', order: 0.4, label: 'Negotiation' } // CONNECT_REQ_SENT + }, + + init: function(bidId, bidStateInd, createdAtTimestamp, stateTimeTimestamp) { + this.bidId = bidId; + this.bidStateInd = bidStateInd; + this.createdAtTimestamp = createdAtTimestamp; + this.stateTimeTimestamp = stateTimeTimestamp; + this.tooltipCounter = 0; + + this.applyStateTooltips(); + this.applyEventTooltips(); + this.createProgressBar(); + this.startElapsedTimeUpdater(); + this.setupAutoRefresh(); + }, + + isActiveState: function() { + return !this.INACTIVE_STATES.includes(this.bidStateInd); + }, + + setupAutoRefresh: function() { + if (!this.isActiveState()) { + return; + } + + const refreshBtn = document.getElementById('refresh'); + if (!refreshBtn) return; + + let countdown = this.AUTO_REFRESH_SECONDS; + const originalSpan = refreshBtn.querySelector('span'); + if (!originalSpan) return; + + const updateCountdown = () => { + originalSpan.textContent = `Auto-refresh in ${countdown}s`; + countdown--; + if (countdown < 0) { + window.location.href = window.location.pathname + window.location.search; + } + }; + + updateCountdown(); + this.autoRefreshInterval = setInterval(updateCountdown, 1000); + + refreshBtn.addEventListener('mouseenter', () => { + if (this.autoRefreshInterval) { + clearInterval(this.autoRefreshInterval); + originalSpan.textContent = 'Click to refresh (paused)'; + } + }); + + refreshBtn.addEventListener('mouseleave', () => { + countdown = this.AUTO_REFRESH_SECONDS; + updateCountdown(); + this.autoRefreshInterval = setInterval(updateCountdown, 1000); + }); + }, + + createTooltip: function(element, tooltipText) { + if (window.TooltipManager && typeof window.TooltipManager.create === 'function') { + try { + const tooltipContent = ` +
+ ${tooltipText} +
+ `; + window.TooltipManager.create(element, tooltipContent, { + placement: 'top' + }); + element.classList.add('cursor-help'); + } catch (e) { + element.setAttribute('title', tooltipText); + element.classList.add('cursor-help'); + } + } else { + element.setAttribute('title', tooltipText); + element.classList.add('cursor-help'); + } + }, + + applyStateTooltips: function() { + const sections = document.querySelectorAll('section'); + let oldStatesSection = null; + + sections.forEach(section => { + const h4 = section.querySelector('h4'); + if (h4 && h4.textContent.includes('Old states')) { + oldStatesSection = section.nextElementSibling; + } + }); + + if (oldStatesSection) { + const table = oldStatesSection.querySelector('table'); + if (table) { + const rows = table.querySelectorAll('tr'); + rows.forEach(row => { + const cells = row.querySelectorAll('td'); + if (cells.length >= 2) { + const stateCell = cells[cells.length - 1]; + const stateText = stateCell.textContent.trim(); + const tooltip = this.STATE_TOOLTIPS[stateText]; + if (tooltip) { + this.addHelpIcon(stateCell, tooltip); + } + } + }); + } + } + + const allRows = document.querySelectorAll('table tr'); + allRows.forEach(row => { + const firstCell = row.querySelector('td'); + if (firstCell) { + const labelText = firstCell.textContent.trim(); + if (labelText === 'Bid State') { + const valueCell = row.querySelectorAll('td')[1]; + if (valueCell) { + const stateText = valueCell.textContent.trim(); + const tooltip = this.STATE_TOOLTIPS[stateText] || this.STATE_TOOLTIPS['Bid ' + stateText]; + if (tooltip) { + this.addHelpIcon(valueCell, tooltip); + } + } + } + } + }); + }, + + addHelpIcon: function(cell, tooltipText) { + if (cell.querySelector('.help-icon')) return; + + const helpIcon = document.createElement('span'); + helpIcon.className = 'help-icon cursor-help inline-flex items-center justify-center w-4 h-4 ml-2 text-xs font-medium text-white bg-blue-500 dark:bg-blue-600 rounded-full hover:bg-blue-600 dark:hover:bg-blue-500'; + helpIcon.textContent = '?'; + helpIcon.style.fontSize = '10px'; + helpIcon.style.verticalAlign = 'middle'; + helpIcon.style.flexShrink = '0'; + + cell.appendChild(helpIcon); + + setTimeout(() => { + this.createTooltip(helpIcon, tooltipText); + }, 50); + }, + + applyEventTooltips: function() { + const sections = document.querySelectorAll('section'); + let eventsSection = null; + + sections.forEach(section => { + const h4 = section.querySelector('h4'); + if (h4 && h4.textContent.includes('Events')) { + eventsSection = section.nextElementSibling; + } + }); + + if (eventsSection) { + const table = eventsSection.querySelector('table'); + if (table) { + const rows = table.querySelectorAll('tr'); + rows.forEach(row => { + const cells = row.querySelectorAll('td'); + if (cells.length >= 2) { + const eventCell = cells[cells.length - 1]; + const eventText = eventCell.textContent.trim(); + + let tooltip = this.EVENT_TOOLTIPS[eventText]; + + if (!tooltip) { + for (const [key, value] of Object.entries(this.EVENT_TOOLTIPS)) { + if (eventText.startsWith(key.replace(':', ''))) { + tooltip = value; + break; + } + } + } + + if (!tooltip && eventText.startsWith('Warning:')) { + tooltip = 'System warning - check message for details'; + } + if (!tooltip && eventText.startsWith('Error:')) { + tooltip = 'Error occurred - check message for details'; + } + if (!tooltip && eventText.startsWith('Temporary RPC error')) { + tooltip = 'Temporary error checking transaction. Will retry automatically'; + } + + if (tooltip) { + this.addHelpIcon(eventCell, tooltip); + } + } + }); + } + } + }, + + createProgressBar: function() { + const phaseInfo = this.STATE_PHASES[this.bidStateInd]; + if (!phaseInfo) return; + + let progressPercent = 0; + const phase = phaseInfo.phase; + + if (phase === 'negotiation') progressPercent = 15; + else if (phase === 'accepted') progressPercent = 30; + else if (phase === 'locking') progressPercent = 55; + else if (phase === 'redemption') progressPercent = 80; + else if (phase === 'complete') progressPercent = 100; + else if (phase === 'failed' || phase === 'error') progressPercent = 100; + + const bidStateRow = document.querySelector('td.bold'); + if (!bidStateRow) return; + + let targetRow = null; + const rows = document.querySelectorAll('table tr'); + rows.forEach(row => { + const firstTd = row.querySelector('td.bold'); + if (firstTd && firstTd.textContent.trim() === 'Bid State') { + targetRow = row; + } + }); + + if (!targetRow) return; + + const progressRow = document.createElement('tr'); + progressRow.className = 'opacity-100 text-gray-500 dark:text-gray-100'; + + const isError = ['failed', 'error'].includes(phase); + const isComplete = phase === 'complete'; + const barColor = isError ? 'bg-red-500' : (isComplete ? 'bg-green-500' : 'bg-blue-500'); + const phaseLabel = isError ? phaseInfo.label : (isComplete ? 'Complete' : `${phaseInfo.label} (${progressPercent}%)`); + + progressRow.innerHTML = ` + Swap Progress + +
+
+
+
+ ${phaseLabel} +
+ + `; + + targetRow.parentNode.insertBefore(progressRow, targetRow.nextSibling); + }, + + startElapsedTimeUpdater: function() { + if (!this.createdAtTimestamp) return; + + let createdAtRow = null; + const rows = document.querySelectorAll('table tr'); + rows.forEach(row => { + const firstTd = row.querySelector('td'); + if (firstTd && firstTd.textContent.includes('Created At')) { + createdAtRow = row; + } + }); + + if (!createdAtRow) return; + + const isCompleted = !this.isActiveState() && this.stateTimeTimestamp; + + const elapsedRow = document.createElement('tr'); + elapsedRow.className = 'opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600'; + + const labelText = isCompleted ? 'Swap Duration' : 'Time Elapsed'; + const iconColor = isCompleted ? '#10B981' : '#3B82F6'; + + elapsedRow.innerHTML = ` + + + + + + + +
+
${labelText}
+
+ + Calculating... + `; + createdAtRow.parentNode.insertBefore(elapsedRow, createdAtRow.nextSibling); + + const elapsedDisplay = document.getElementById('elapsed-time-display'); + + if (isCompleted) { + const duration = this.stateTimeTimestamp - this.createdAtTimestamp; + elapsedDisplay.textContent = this.formatDuration(duration); + } else { + const updateElapsed = () => { + const now = Math.floor(Date.now() / 1000); + const elapsed = now - this.createdAtTimestamp; + elapsedDisplay.textContent = this.formatDuration(elapsed); + }; + + updateElapsed(); + this.elapsedTimeInterval = setInterval(updateElapsed, 1000); + } + }, + + formatDuration: function(seconds) { + if (seconds < 60) { + return `${seconds} second${seconds !== 1 ? 's' : ''}`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + const remainingSeconds = seconds % 60; + if (remainingSeconds > 0) { + return `${minutes} min ${remainingSeconds} sec`; + } + return `${minutes} minute${minutes !== 1 ? 's' : ''}`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + if (hours < 24) { + if (remainingMinutes > 0) { + return `${hours} hr ${remainingMinutes} min`; + } + return `${hours} hour${hours !== 1 ? 's' : ''}`; + } + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + if (remainingHours > 0) { + return `${days} day${days !== 1 ? 's' : ''} ${remainingHours} hr`; + } + return `${days} day${days !== 1 ? 's' : ''}`; + } +}; diff --git a/basicswap/static/js/pages/offer-page.js b/basicswap/static/js/pages/offer-page.js index 85b4b22..f62afaa 100644 --- a/basicswap/static/js/pages/offer-page.js +++ b/basicswap/static/js/pages/offer-page.js @@ -41,12 +41,29 @@ setupEventListeners: function() { const sendBidBtn = document.querySelector('button[name="sendbid"][value="Send Bid"]'); if (sendBidBtn) { - sendBidBtn.onclick = this.showConfirmModal.bind(this); + sendBidBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.showConfirmModal(); + }); } - const modalCancelBtn = document.querySelector('#confirmModal .flex button:last-child'); + const modalCancelBtn = document.querySelector('#confirmModal [data-hide-modal]'); if (modalCancelBtn) { - modalCancelBtn.onclick = this.hideConfirmModal.bind(this); + modalCancelBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.hideConfirmModal(); + }); + } + + const confirmModal = document.getElementById('confirmModal'); + if (confirmModal) { + confirmModal.addEventListener('click', (e) => { + if (e.target === confirmModal || e.target.classList.contains('bg-opacity-50')) { + this.hideConfirmModal(); + } + }); } const mainCancelBtn = document.querySelector('button[name="cancel"]'); diff --git a/basicswap/static/js/pages/settings-page.js b/basicswap/static/js/pages/settings-page.js index 4ef246e..5a86462 100644 --- a/basicswap/static/js/pages/settings-page.js +++ b/basicswap/static/js/pages/settings-page.js @@ -6,11 +6,15 @@ confirmCallback: null, triggerElement: null, + originalConnectionTypes: {}, + init: function() { this.setupTabs(); this.setupCoinHeaders(); this.setupConfirmModal(); this.setupNotificationSettings(); + this.setupMigrationIndicator(); + this.setupServerDiscovery(); }, setupTabs: function() { @@ -61,6 +65,144 @@ }); }, + pendingModeSwitch: null, + + setupMigrationIndicator: function() { + const connectionTypeSelects = document.querySelectorAll('select[name^="connection_type_"]'); + connectionTypeSelects.forEach(select => { + const originalValue = select.dataset.originalValue || select.value; + this.originalConnectionTypes[select.name] = originalValue; + }); + + this.setupWalletModeModal(); + + const coinsForm = document.getElementById('coins-form'); + if (coinsForm) { + coinsForm.addEventListener('submit', (e) => { + const submitter = e.submitter; + if (!submitter || !submitter.name.startsWith('apply_')) return; + + const coinName = submitter.name.replace('apply_', ''); + const select = document.querySelector(`select[name="connection_type_${coinName}"]`); + if (!select) return; + + const original = this.originalConnectionTypes[select.name]; + const current = select.value; + + if (original && current && original !== current) { + e.preventDefault(); + const direction = (original === 'rpc' && current === 'electrum') ? 'lite' : 'rpc'; + this.showWalletModeConfirmation(coinName, direction, submitter); + } + }); + } + }, + + setupWalletModeModal: function() { + const confirmBtn = document.getElementById('walletModeConfirm'); + const cancelBtn = document.getElementById('walletModeCancel'); + + if (confirmBtn) { + confirmBtn.addEventListener('click', () => { + this.hideWalletModeModal(); + if (this.pendingModeSwitch) { + const { coinName, direction, submitter } = this.pendingModeSwitch; + this.showMigrationModal(coinName.toUpperCase(), direction); + const form = submitter.form; + const hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + hiddenInput.name = submitter.name; + hiddenInput.value = submitter.value; + form.appendChild(hiddenInput); + form.submit(); + } + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + this.hideWalletModeModal(); + if (this.pendingModeSwitch) { + const { coinName } = this.pendingModeSwitch; + const select = document.querySelector(`select[name="connection_type_${coinName}"]`); + if (select) { + select.value = this.originalConnectionTypes[select.name]; + } + } + this.pendingModeSwitch = null; + }); + } + }, + + showWalletModeConfirmation: function(coinName, direction, submitter) { + const modal = document.getElementById('walletModeModal'); + const title = document.getElementById('walletModeTitle'); + const message = document.getElementById('walletModeMessage'); + const details = document.getElementById('walletModeDetails'); + + if (!modal || !title || !message || !details) return; + + this.pendingModeSwitch = { coinName, direction, submitter }; + + const displayName = coinName.charAt(0).toUpperCase() + coinName.slice(1).toLowerCase(); + + if (direction === 'lite') { + title.textContent = `Switch ${displayName} to Lite Wallet Mode`; + message.textContent = 'Please confirm you want to switch to lite wallet mode.'; + details.innerHTML = ` +

Before switching:

+ +

+ Note: Your balance will remain accessible - same seed means same funds in both modes. +

+ `; + } else { + title.textContent = `Switch ${displayName} to Full Node Mode`; + message.textContent = 'Please confirm you want to switch to full node mode.'; + details.innerHTML = ` +

Switching to full node mode:

+ +

+ Note: Your balance will remain accessible - same seed means same funds in both modes. +

+ `; + } + + modal.classList.remove('hidden'); + }, + + hideWalletModeModal: function() { + const modal = document.getElementById('walletModeModal'); + if (modal) { + modal.classList.add('hidden'); + } + }, + + showMigrationModal: function(coinName, direction) { + const modal = document.getElementById('migrationModal'); + const title = document.getElementById('migrationTitle'); + const message = document.getElementById('migrationMessage'); + + if (modal && title && message) { + if (direction === 'lite') { + title.textContent = `Migrating ${coinName} to Lite Wallet`; + message.textContent = 'Checking wallet balance and migrating addresses. Please wait...'; + } else { + title.textContent = `Switching ${coinName} to Full Node`; + message.textContent = 'Syncing wallet indices. Please wait...'; + } + modal.classList.remove('hidden'); + } + }, + setupConfirmModal: function() { const confirmYesBtn = document.getElementById('confirmYes'); if (confirmYesBtn) { @@ -307,6 +449,166 @@ } }; + SettingsPage.setupServerDiscovery = function() { + const discoverBtns = document.querySelectorAll('.discover-servers-btn'); + discoverBtns.forEach(btn => { + btn.addEventListener('click', () => { + const coin = btn.dataset.coin; + this.discoverServers(coin, btn); + }); + }); + + const closeBtns = document.querySelectorAll('.close-discovered-btn'); + closeBtns.forEach(btn => { + btn.addEventListener('click', () => { + const coin = btn.dataset.coin; + const panel = document.getElementById(`discovered-servers-${coin}`); + if (panel) panel.classList.add('hidden'); + }); + }); + }; + + SettingsPage.discoverServers = function(coin, button) { + const originalHtml = button.innerHTML; + button.innerHTML = `Discovering...`; + button.disabled = true; + + const panel = document.getElementById(`discovered-servers-${coin}`); + const listContainer = document.getElementById(`discovered-list-${coin}`); + + fetch('/json/electrumdiscover', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ coin: coin, ping: true }) + }) + .then(response => response.json()) + .then(data => { + if (data.error) { + listContainer.innerHTML = `
${data.error}
`; + } else { + let html = ''; + + if (data.current_server) { + html += ` +
+ + + Connected to: ${data.current_server.host}:${data.current_server.port} + +
`; + } + + if (data.clearnet_servers && data.clearnet_servers.length > 0) { + html += ` +
+
+ Clearnet +
+
`; + data.clearnet_servers.forEach(srv => { + const statusClass = srv.online ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'; + const statusText = srv.online ? (srv.latency_ms ? srv.latency_ms.toFixed(0) + 'ms' : 'online') : 'offline'; + const statusDot = srv.online ? 'bg-green-500' : 'bg-gray-400'; + html += ` +
+
+ + + + ${srv.host}:${srv.port} +
+
+ + ${statusText} +
+
`; + }); + html += ` +
+
`; + } + + if (data.onion_servers && data.onion_servers.length > 0) { + html += ` +
+
+ TOR (.onion) +
+
`; + data.onion_servers.forEach(srv => { + const statusClass = srv.online ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'; + const statusText = srv.online ? (srv.latency_ms ? srv.latency_ms.toFixed(0) + 'ms' : 'online') : 'offline'; + const statusDot = srv.online ? 'bg-green-500' : 'bg-gray-400'; + html += ` +
+
+ + + + ${srv.host.substring(0, 24)}...:${srv.port} +
+
+ + ${statusText} +
+
`; + }); + html += ` +
+
`; + } + + if (!data.clearnet_servers?.length && !data.onion_servers?.length) { + html = '
No servers discovered. The connected server may not support peer discovery.
'; + } else { + html += `
Click a server to add it to your list
`; + } + + listContainer.innerHTML = html; + + listContainer.querySelectorAll('.add-server-btn').forEach(item => { + item.addEventListener('click', () => { + const host = item.dataset.host; + const port = item.dataset.port; + const type = item.dataset.type; + const coinName = item.dataset.coin; + + const textareaId = type === 'onion' ? + `electrum_onion_${coinName}` : `electrum_clearnet_${coinName}`; + const textarea = document.getElementById(textareaId); + + if (textarea) { + const serverLine = `${host}:${port}`; + const currentValue = textarea.value.trim(); + + if (currentValue.split('\n').some(line => line.trim() === serverLine)) { + item.classList.add('bg-yellow-100', 'dark:bg-yellow-800/30'); + setTimeout(() => item.classList.remove('bg-yellow-100', 'dark:bg-yellow-800/30'), 500); + return; + } + + textarea.value = currentValue ? currentValue + '\n' + serverLine : serverLine; + item.classList.add('bg-green-100', 'dark:bg-green-800/30'); + setTimeout(() => item.classList.remove('bg-green-100', 'dark:bg-green-800/30'), 500); + } + }); + }); + } + + panel.classList.remove('hidden'); + }) + .catch(err => { + listContainer.innerHTML = `
Failed to discover servers: ${err.message}
`; + panel.classList.remove('hidden'); + }) + .finally(() => { + button.innerHTML = originalHtml; + button.disabled = false; + }); + }; + SettingsPage.cleanup = function() { }; diff --git a/basicswap/static/js/pages/wallet-page.js b/basicswap/static/js/pages/wallet-page.js index 4aae02d..ce510f4 100644 --- a/basicswap/static/js/pages/wallet-page.js +++ b/basicswap/static/js/pages/wallet-page.js @@ -13,6 +13,7 @@ this.setupWithdrawalConfirmation(); this.setupTransactionDisplay(); this.setupWebSocketUpdates(); + this.setupTransactionPagination(); }, setupAddressCopy: function() { @@ -340,13 +341,289 @@ }, handleBalanceUpdate: function(balanceData) { - - console.log('Balance updated:', balanceData); + if (!balanceData || !Array.isArray(balanceData)) return; + + const coinId = this.currentCoinId; + if (!coinId) return; + + const matchingCoins = balanceData.filter(coin => + coin.ticker && coin.ticker.toLowerCase() === coinId.toLowerCase() + ); + + matchingCoins.forEach(coinData => { + const balanceElements = document.querySelectorAll('.coinname-value[data-coinname]'); + balanceElements.forEach(element => { + const elementCoinName = element.getAttribute('data-coinname'); + if (elementCoinName === coinData.name) { + const currentText = element.textContent; + const ticker = coinData.ticker || coinId.toUpperCase(); + const newBalance = `${coinData.balance} ${ticker}`; + if (currentText !== newBalance) { + element.textContent = newBalance; + console.log(`Updated balance: ${coinData.name} -> ${newBalance}`); + } + } + }); + + this.updatePendingForCoin(coinData); + }); + + this.refreshTransactions(); + }, + + updatePendingForCoin: function(coinData) { + const pendingAmount = parseFloat(coinData.pending || '0'); + + + const pendingElements = document.querySelectorAll('.inline-block.py-1.px-2.rounded-full.bg-green-100'); + + pendingElements.forEach(el => { + const text = el.textContent || ''; + + if (text.includes('Pending:') && text.includes(coinData.ticker)) { + if (pendingAmount > 0) { + el.textContent = `Pending: +${coinData.pending} ${coinData.ticker}`; + el.style.display = ''; + } else { + el.style.display = 'none'; + } + } + }); + }, + + refreshTransactions: function() { + const txTable = document.querySelector('#transaction-history-section tbody'); + if (txTable) { + const pathParts = window.location.pathname.split('/'); + const ticker = pathParts[pathParts.length - 1]; + + fetch(`/json/wallettransactions/${ticker}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ page_no: 1 }) + }) + .then(response => response.json()) + .then(data => { + if (data.transactions && data.transactions.length > 0) { + const currentPageSpan = document.getElementById('currentPageTx'); + const totalPagesSpan = document.getElementById('totalPagesTx'); + if (currentPageSpan) currentPageSpan.textContent = data.page_no; + if (totalPagesSpan) totalPagesSpan.textContent = data.total_pages; + } + }) + .catch(error => console.error('Error refreshing transactions:', error)); + } }, handleSwapEvent: function(eventData) { - - console.log('Swap event:', eventData); + if (window.BalanceUpdatesManager) { + window.BalanceUpdatesManager.fetchBalanceData() + .then(data => this.handleBalanceUpdate(data)) + .catch(error => console.error('Error updating balance after swap:', error)); + } + }, + + setupTransactionPagination: function() { + const txContainer = document.getElementById('tx-container'); + if (!txContainer) return; + + const pathParts = window.location.pathname.split('/'); + const ticker = pathParts[pathParts.length - 1]; + + let currentPage = 1; + let totalPages = 1; + let isLoading = false; + + const prevBtn = document.getElementById('prevPageTx'); + const nextBtn = document.getElementById('nextPageTx'); + const currentPageSpan = document.getElementById('currentPageTx'); + const totalPagesSpan = document.getElementById('totalPagesTx'); + const paginationControls = document.getElementById('tx-pagination-section'); + + const copyToClipboard = (text, button) => { + const showSuccess = () => { + const originalHTML = button.innerHTML; + button.innerHTML = ` + + `; + setTimeout(() => { + button.innerHTML = originalHTML; + }, 1500); + }; + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(showSuccess).catch(err => { + console.error('Clipboard API failed:', err); + fallbackCopy(text, showSuccess); + }); + } else { + fallbackCopy(text, showSuccess); + } + }; + + const fallbackCopy = (text, onSuccess) => { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy'); + onSuccess(); + } catch (err) { + console.error('Fallback copy failed:', err); + } + document.body.removeChild(textArea); + }; + + const loadTransactions = async (page) => { + if (isLoading) return; + isLoading = true; + + try { + const response = await fetch(`/json/wallettransactions/${ticker}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ page_no: page }) + }); + + const data = await response.json(); + + if (data.error) { + console.error('Error loading transactions:', data.error); + return; + } + + currentPage = data.page_no; + totalPages = data.total_pages; + + currentPageSpan.textContent = currentPage; + totalPagesSpan.textContent = totalPages; + + txContainer.innerHTML = ''; + + if (data.transactions && data.transactions.length > 0) { + data.transactions.forEach(tx => { + const card = document.createElement('div'); + card.className = 'bg-white dark:bg-gray-600 rounded-lg border border-gray-200 dark:border-gray-500 p-4 hover:shadow-md transition-shadow'; + + let typeClass = 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'; + let amountClass = 'text-gray-700 dark:text-gray-200'; + let typeIcon = ''; + let amountPrefix = ''; + if (tx.type === 'Incoming') { + typeClass = 'bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-300'; + amountClass = 'text-green-600 dark:text-green-400'; + typeIcon = '↓'; + amountPrefix = '+'; + } else if (tx.type === 'Outgoing') { + typeClass = 'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300'; + amountClass = 'text-red-600 dark:text-red-400'; + typeIcon = '↑'; + amountPrefix = '-'; + } + + let confirmClass = 'text-gray-600 dark:text-gray-300'; + if (tx.confirmations === 0) { + confirmClass = 'text-yellow-600 dark:text-yellow-400 font-medium'; + } else if (tx.confirmations >= 1 && tx.confirmations <= 5) { + confirmClass = 'text-blue-600 dark:text-blue-400'; + } else if (tx.confirmations >= 6) { + confirmClass = 'text-green-600 dark:text-green-400'; + } + + card.innerHTML = ` +
+
+ + ${typeIcon} ${tx.type} + + + ${amountPrefix}${tx.amount} ${ticker.toUpperCase()} + +
+
+ ${tx.confirmations} Confirmations + ${tx.timestamp} +
+
+ ${tx.address ? ` +
+ Address: + ${tx.address} + +
+ ` : ''} +
+ Txid: + ${tx.txid} + +
+ `; + + const copyAddressBtn = card.querySelector('.copy-address-btn'); + if (copyAddressBtn) { + copyAddressBtn.addEventListener('click', () => copyToClipboard(tx.address, copyAddressBtn)); + } + + const copyTxidBtn = card.querySelector('.copy-txid-btn'); + if (copyTxidBtn) { + copyTxidBtn.addEventListener('click', () => copyToClipboard(tx.txid, copyTxidBtn)); + } + + txContainer.appendChild(card); + }); + + if (totalPages > 1 && paginationControls) { + paginationControls.style.display = 'block'; + } else if (paginationControls) { + paginationControls.style.display = 'none'; + } + } else { + txContainer.innerHTML = '
No transactions found
'; + if (paginationControls) paginationControls.style.display = 'none'; + } + + prevBtn.style.display = currentPage > 1 ? 'inline-flex' : 'none'; + nextBtn.style.display = currentPage < totalPages ? 'inline-flex' : 'none'; + + } catch (error) { + console.error('Error fetching transactions:', error); + } finally { + isLoading = false; + } + }; + + if (prevBtn) { + prevBtn.addEventListener('click', () => { + if (currentPage > 1) { + loadTransactions(currentPage - 1); + } + }); + } + + if (nextBtn) { + nextBtn.addEventListener('click', () => { + if (currentPage < totalPages) { + loadTransactions(currentPage + 1); + } + }); + } + + loadTransactions(1); } }; diff --git a/basicswap/static/js/pages/wallets-page.js b/basicswap/static/js/pages/wallets-page.js index 65fe86d..ac22c8d 100644 --- a/basicswap/static/js/pages/wallets-page.js +++ b/basicswap/static/js/pages/wallets-page.js @@ -86,6 +86,56 @@ } } } + + if (coinData.scan_status) { + this.updateScanStatus(coinData); + } + + if (coinData.version) { + const versionEl = document.querySelector(`.electrum-version[data-coin="${coinData.name}"]`); + if (versionEl && versionEl.textContent !== coinData.version) { + versionEl.textContent = coinData.version; + } + } + if (coinData.electrum_server) { + const serverEl = document.querySelector(`.electrum-server[data-coin="${coinData.name}"]`); + if (serverEl && serverEl.textContent !== coinData.electrum_server) { + serverEl.textContent = coinData.electrum_server; + } + } + }, + + updateScanStatus: function(coinData) { + const scanStatusEl = document.querySelector(`.scan-status[data-coin="${coinData.name}"]`); + if (!scanStatusEl) return; + + const status = coinData.scan_status; + if (status.in_progress) { + scanStatusEl.innerHTML = ` +
+ + + + + + Scanning ${status.status} + + ${status.progress}% +
+
+
+
+ `; + } else { + scanStatusEl.innerHTML = ` +
+ + + + Electrum Wallet Synced +
+ `; + } }, updateSpecificBalance: function(coinName, labelText, balance, ticker, isPending = false) { @@ -102,12 +152,13 @@ const currentLabel = labelElement.textContent.trim(); if (currentLabel === labelText) { + const cleanBalance = balance.toString().replace(/^\+/, ''); if (isPending) { - const cleanBalance = balance.toString().replace(/^\+/, ''); element.textContent = `+${cleanBalance} ${ticker}`; } else { element.textContent = `${balance} ${ticker}`; } + element.setAttribute('data-original-value', `${cleanBalance} ${ticker}`); } } } @@ -139,6 +190,7 @@ if (pendingSpan) { const cleanPending = coinData.pending.toString().replace(/^\+/, ''); pendingSpan.textContent = `+${cleanPending} ${coinData.ticker || coinData.name}`; + pendingSpan.setAttribute('data-original-value', `+${cleanPending} ${coinData.ticker || coinData.name}`); } let initialUSD = '$0.00'; @@ -218,7 +270,7 @@ const balanceElements = document.querySelectorAll('.coinname-value[data-coinname]'); for (const element of balanceElements) { if (element.getAttribute('data-coinname') === coinName) { - return element.closest('.bg-white, .dark\\:bg-gray-500'); + return element.closest('.bg-gray-50, .dark\\:bg-gray-500'); } } return null; @@ -330,6 +382,7 @@ if (pendingSpan) { const cleanPending = pendingAmount.toString().replace(/^\+/, ''); pendingSpan.textContent = `+${cleanPending} ${ticker}`; + pendingSpan.setAttribute('data-original-value', `+${cleanPending} ${ticker}`); } } } diff --git a/basicswap/templates/bid.html b/basicswap/templates/bid.html index 66fd2e3..df1f70c 100644 --- a/basicswap/templates/bid.html +++ b/basicswap/templates/bid.html @@ -525,14 +525,14 @@ {% if data.can_abandon == true and not edit_bid %}
- +
{% endif %} {% endif %} {% endif %} {% if data.was_received and not edit_bid and data.can_accept_bid %}
- +
{% endif %} @@ -689,6 +689,12 @@ document.addEventListener('DOMContentLoaded', function() { overrideButtonConfirm(acceptBidBtn, 'Accept'); }); + + {% include 'footer.html' %} diff --git a/basicswap/templates/bid_xmr.html b/basicswap/templates/bid_xmr.html index b78848c..cc0bc7c 100644 --- a/basicswap/templates/bid_xmr.html +++ b/basicswap/templates/bid_xmr.html @@ -801,14 +801,14 @@ {% if data.can_abandon == true %}
- +
{% endif %} {% endif %} {% endif %} {% if data.was_received and not edit_bid and data.can_accept_bid %}
- +
{% endif %} @@ -965,6 +965,12 @@ document.addEventListener('DOMContentLoaded', function() { overrideButtonConfirm(acceptBidBtn, 'Accept'); }); + + {% include 'footer.html' %} diff --git a/basicswap/templates/footer.html b/basicswap/templates/footer.html index 01d6880..5ebb921 100644 --- a/basicswap/templates/footer.html +++ b/basicswap/templates/footer.html @@ -26,7 +26,7 @@

© 2025~ (BSX) BasicSwap

BSX: v{{ version }}

-

GUI: v3.3.1

+

GUI: v3.4.0

Made with

{{ love_svg | safe }}
diff --git a/basicswap/templates/settings.html b/basicswap/templates/settings.html index 716dad0..8c351c5 100644 --- a/basicswap/templates/settings.html +++ b/basicswap/templates/settings.html @@ -105,17 +105,31 @@

Connection

- +
- +
+ {% if c.supports_electrum %} +
+ +
+ + + +
+
+ {% else %}
{{ c.connection_type }}
+ {% endif %}
- + {% if c.manage_daemon is defined %}
@@ -138,12 +152,164 @@ {% endif %}
{% endif %} - +
{% endif %} + {% if c.supports_electrum %} +
+

+ Mode Information +

+ +
+ +
+

To Light Wallet:

+
    +
  • • Your full node stops running
  • +
  • • Light wallet uses your seed to access existing funds
  • +
  • • No transfer needed - same seed, same funds
  • +
+
+ +
+

While in Light Wallet mode:

+
    +
  • • Light wallet generates NEW addresses (BIP84 format: bc1q.../ltc1q...)
  • +
  • • Any funds you RECEIVE go to these new addresses
  • +
  • • Your full node doesn't know about these addresses
  • +
+
+ +
+

To Full Node:

+
    +
  • • Full node can't see funds on light wallet addresses
  • +
  • • These funds must be SENT back to your node wallet (real transaction, network fee applies based on current rate)
  • +
  • • Enable "Auto-transfer" in Fund Transfer section to do this automatically on unlock
  • +
+
+ +
+

Active Swaps: Complete all swaps before switching modes.

+ {% if c.name == 'litecoin' %} +

MWEB: Not supported in light wallet mode.

+ {% endif %} +

If TOR enabled: Electrum connections routed through TOR.

+
+ +
+
+ + {% if c.connection_type == 'electrum' %} +
+

+ Electrum Servers +

+ +
+
Clearnet
+
+ +

One per line. Format: host:port (50002=SSL, 50001=non-SSL)

+
+
+ +
+
TOR (.onion)
+
+ +

One per line. Used when TOR is enabled.

+
+
+ + +
+ +
+ + + +
+
+

+ Fund Transfer +

+ +
+ {% if c.lite_wallet_balance %} +
+

+ + Light Wallet Balance Detected +

+
+

Confirmed: {{ "%.8f"|format(c.lite_wallet_balance.confirmed) }} {{ c.display_name }}

+ {% if c.lite_wallet_balance.unconfirmed > 0 %} +

Unconfirmed: {{ "%.8f"|format(c.lite_wallet_balance.unconfirmed) }} {{ c.display_name }}

+ {% endif %} +

+ {% if c.lite_wallet_balance.is_pending_sweep %} + Sweep pending - waiting for confirmations... + {% else %} + These funds will be swept to your RPC wallet automatically. + {% endif %} +

+ {% if c.lite_wallet_balance.confirmed > 0 %} +
+ +
+ {% endif %} +
+
+ {% endif %} + +

Funds in light wallet addresses will be swept to your RPC wallet after switching. Network fee applies based on current rate.

+ + {% if general_settings.debug %} +
+ +
+ + (5-100) +
+

Number of consecutive unfunded addresses to scan. Increase if you generated many unused addresses.

+
+ {% endif %} +
+
+ {% endif %} + {% endif %} + {% if c.name in ('wownero', 'monero') %}

@@ -656,6 +822,53 @@

+ + + + {% include 'footer.html' %} diff --git a/basicswap/templates/unlock.html b/basicswap/templates/unlock.html index 3790547..480cc52 100644 --- a/basicswap/templates/unlock.html +++ b/basicswap/templates/unlock.html @@ -101,7 +101,7 @@ -
MWEB Address:
-
{{ w.mweb_address }}
+
{{ w.mweb_address }}
@@ -333,6 +412,7 @@
+ {% endif %} {# / LTC #} {% endif %} @@ -349,10 +429,6 @@ - - - -
@@ -393,8 +469,12 @@ {{ w.name }} MWEB Balance: + {% if is_electrum_mode %} + Not available in light mode + {% else %} {{ w.mweb_balance }} {{ w.ticker }} () + {% endif %} {% elif w.cid == '1' %} @@ -541,7 +621,7 @@ {# / PART #} - {% elif w.cid == '3' %} {# LTC #} + {% elif w.cid == '3' and not is_electrum_mode %} {# LTC - only show in full node mode #} Type From: @@ -593,6 +673,8 @@
{# / XMR | WOW #} {% elif w.show_utxo_groups %} + {% elif is_electrum_mode %} + {# Hide UTXO Groups button in electrum/lite wallet mode #} {% else %}
{% endif %} @@ -680,6 +762,96 @@ +{% if w.havedata and not w.error %} +
+
+
+

Transaction History

+
+
+
+ +
+
+
+
+
+
+
+
+ {% if is_electrum_mode %} +
+ Transaction history is not available in Light Wallet mode. +
+ {% else %} +
+
Loading transactions...
+
+ {% endif %} +
+
+
+
+
+
+
+ + {% if not is_electrum_mode %} + + {% endif %} + + {% if is_electrum_mode %} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% endif %} + +{% endif %} + {% endif %} diff --git a/basicswap/ui/page_amm.py b/basicswap/ui/page_amm.py index f1e1e21..87c661f 100644 --- a/basicswap/ui/page_amm.py +++ b/basicswap/ui/page_amm.py @@ -1253,7 +1253,8 @@ def amm_autostart_api(swap_client, post_string, params=None): settings_path = os.path.join(swap_client.data_dir, cfg.CONFIG_FILENAME) settings_path_new = settings_path + ".new" - shutil.copyfile(settings_path, settings_path + ".last") + if os.path.exists(settings_path): + shutil.copyfile(settings_path, settings_path + ".last") with open(settings_path_new, "w") as fp: json.dump(swap_client.settings, fp, indent=4) shutil.move(settings_path_new, settings_path) diff --git a/basicswap/ui/page_offers.py b/basicswap/ui/page_offers.py index 6572d9c..b4ecd4f 100644 --- a/basicswap/ui/page_offers.py +++ b/basicswap/ui/page_offers.py @@ -218,6 +218,8 @@ def parseOfferFormData(swap_client, form_data, page_data, options={}): if have_data_entry(form_data, "fee_from_extra"): page_data["fee_from_extra"] = int(get_data_entry(form_data, "fee_from_extra")) parsed_data["fee_from_extra"] = page_data["fee_from_extra"] + else: + page_data["fee_from_extra"] = 0 if have_data_entry(form_data, "fee_to_conf"): page_data["fee_to_conf"] = int(get_data_entry(form_data, "fee_to_conf")) @@ -226,6 +228,8 @@ def parseOfferFormData(swap_client, form_data, page_data, options={}): if have_data_entry(form_data, "fee_to_extra"): page_data["fee_to_extra"] = int(get_data_entry(form_data, "fee_to_extra")) parsed_data["fee_to_extra"] = page_data["fee_to_extra"] + else: + page_data["fee_to_extra"] = 0 if have_data_entry(form_data, "check_offer"): page_data["check_offer"] = True diff --git a/basicswap/ui/page_settings.py b/basicswap/ui/page_settings.py index 2b50857..1d1c832 100644 --- a/basicswap/ui/page_settings.py +++ b/basicswap/ui/page_settings.py @@ -104,6 +104,10 @@ def page_settings(self, url_split, post_string): "TODO: If running in docker see doc/tor.md to enable/disable tor." ) + electrum_supported_coins = ( + "bitcoin", + "litecoin", + ) for name, c in swap_client.settings["chainclients"].items(): if have_data_entry(form_data, "apply_" + name): data = {"lookups": get_data_entry(form_data, "lookups_" + name)} @@ -138,10 +142,67 @@ def page_settings(self, url_split, post_string): data["anon_tx_ring_size"] = int( get_data_entry(form_data, "rct_ring_size_" + name) ) + if name in electrum_supported_coins: + new_connection_type = get_data_entry_or( + form_data, "connection_type_" + name, None + ) + if new_connection_type and new_connection_type != c.get( + "connection_type" + ): + coin_id = swap_client.getCoinIdFromName(name) + has_active_swaps = False + for bid_id, (bid, offer) in list( + swap_client.swaps_in_progress.items() + ): + if ( + offer.coin_from == coin_id + or offer.coin_to == coin_id + ): + has_active_swaps = True + break + if has_active_swaps: + display_name = getCoinName(coin_id) + err_messages.append( + f"Cannot change {display_name} connection mode while swaps are in progress. " + f"Please wait for all {display_name} swaps to complete." + ) + else: + data["connection_type"] = new_connection_type + if new_connection_type == "electrum": + data["manage_daemon"] = False + elif new_connection_type == "rpc": + data["manage_daemon"] = True + clearnet_servers = get_data_entry_or( + form_data, "electrum_clearnet_" + name, "" + ).strip() + data["electrum_clearnet_servers"] = clearnet_servers + onion_servers = get_data_entry_or( + form_data, "electrum_onion_" + name, "" + ).strip() + data["electrum_onion_servers"] = onion_servers + auto_transfer = have_data_entry( + form_data, "auto_transfer_" + name + ) + data["auto_transfer_on_mode_switch"] = auto_transfer + # Address gap limit for scanning + gap_limit_str = get_data_entry_or( + form_data, "gap_limit_" + name, "20" + ).strip() + try: + gap_limit = int(gap_limit_str) + if gap_limit < 5: + gap_limit = 5 + elif gap_limit > 100: + gap_limit = 100 + data["address_gap_limit"] = gap_limit + except ValueError: + pass - settings_changed, suggest_reboot = swap_client.editSettings( - name, data + settings_changed, suggest_reboot, migration_message = ( + swap_client.editSettings(name, data) ) + if migration_message: + messages.append(migration_message) if settings_changed is True: messages.append("Settings applied.") if suggest_reboot is True: @@ -156,19 +217,65 @@ def page_settings(self, url_split, post_string): display_name = getCoinName(swap_client.getCoinIdFromName(name)) messages.append(display_name + " disabled, shutting down.") swap_client.stopRunning() + elif have_data_entry(form_data, "force_sweep_" + name): + coin_id = swap_client.getCoinIdFromName(name) + display_name = getCoinName(coin_id) + try: + result = swap_client.sweepLiteWalletFunds(coin_id) + if result.get("success"): + amount = result.get("amount", 0) + fee = result.get("fee", 0) + txid = result.get("txid", "") + messages.append( + f"Successfully swept {amount:.8f} {display_name} to RPC wallet. " + f"Fee: {fee:.8f}. TXID: {txid} (1 confirmation required)" + ) + elif result.get("skipped"): + messages.append( + f"{display_name}: {result.get('reason', 'Sweep skipped')}" + ) + else: + err_messages.append( + f"{display_name}: Sweep failed - {result.get('error', 'Unknown error')}" + ) + except Exception as e: + err_messages.append(f"{display_name}: Sweep failed - {str(e)}") except InactiveCoin as ex: err_messages.append("InactiveCoin {}".format(Coins(ex.coinid).name)) except Exception as e: err_messages.append(str(e)) chains_formatted = [] + electrum_supported_coins = ( + "bitcoin", + "litecoin", + ) sorted_names = sorted(swap_client.settings["chainclients"].keys()) + from basicswap.interface.electrumx import ( + DEFAULT_ELECTRUM_SERVERS, + DEFAULT_ONION_SERVERS, + ) + for name in sorted_names: c = swap_client.settings["chainclients"][name] try: display_name = getCoinName(swap_client.getCoinIdFromName(name)) except Exception: display_name = name + + clearnet_servers = c.get("electrum_clearnet_servers", None) + onion_servers = c.get("electrum_onion_servers", None) + + if clearnet_servers is None: + default_clearnet = DEFAULT_ELECTRUM_SERVERS.get(name, []) + clearnet_servers = [f"{s['host']}:{s['port']}" for s in default_clearnet] + if onion_servers is None: + default_onion = DEFAULT_ONION_SERVERS.get(name, []) + onion_servers = [f"{s['host']}:{s['port']}" for s in default_onion] + + clearnet_text = "\n".join(clearnet_servers) if clearnet_servers else "" + onion_text = "\n".join(onion_servers) if onion_servers else "" + chains_formatted.append( { "name": name, @@ -176,6 +283,13 @@ def page_settings(self, url_split, post_string): "lookups": c.get("chain_lookups", "local"), "manage_daemon": c.get("manage_daemon", "Unknown"), "connection_type": c.get("connection_type", "Unknown"), + "supports_electrum": name in electrum_supported_coins, + "clearnet_servers_text": clearnet_text, + "onion_servers_text": onion_text, + "auto_transfer_on_mode_switch": c.get( + "auto_transfer_on_mode_switch", True + ), + "address_gap_limit": c.get("address_gap_limit", 20), } ) if name in ("monero", "wownero"): @@ -203,6 +317,14 @@ def page_settings(self, url_split, post_string): else: chains_formatted[-1]["can_disable"] = True + try: + coin_id = swap_client.getCoinIdFromName(name) + lite_balance_info = swap_client.getLiteWalletBalanceInfo(coin_id) + if lite_balance_info: + chains_formatted[-1]["lite_wallet_balance"] = lite_balance_info + except Exception: + pass + general_settings = { "debug": swap_client.debug, "debug_ui": swap_client.debug_ui, diff --git a/basicswap/ui/page_wallet.py b/basicswap/ui/page_wallet.py index 04aa9ef..d2e0429 100644 --- a/basicswap/ui/page_wallet.py +++ b/basicswap/ui/page_wallet.py @@ -32,11 +32,15 @@ DONATION_ADDRESSES = { def format_wallet_data(swap_client, ci, w): + coin_id = ci.coin_type() + connection_type = swap_client.coin_clients.get(coin_id, {}).get( + "connection_type", w.get("connection_type", "rpc") + ) wf = { "name": ci.coin_name(), "version": w.get("version", "?"), "ticker": ci.ticker_mainnet(), - "cid": str(int(ci.coin_type())), + "cid": str(int(coin_id)), "balance": w.get("balance", "?"), "blocks": w.get("blocks", "?"), "synced": w.get("synced", "?"), @@ -45,6 +49,7 @@ def format_wallet_data(swap_client, ci, w): "locked": w.get("locked", "?"), "updating": w.get("updating", "?"), "havedata": True, + "connection_type": connection_type, } if "wallet_blocks" in w: @@ -70,6 +75,9 @@ def format_wallet_data(swap_client, ci, w): if pending > 0.0: wf["pending"] = ci.format_amount(pending) + if "unconfirmed" in w and float(w["unconfirmed"]) < 0.0: + wf["pending_out"] = ci.format_amount(abs(ci.make_int(w["unconfirmed"]))) + if ci.coin_type() == Coins.PART: wf["stealth_address"] = w.get("stealth_address", "?") wf["blind_balance"] = w.get("blind_balance", "?") @@ -83,10 +91,85 @@ def format_wallet_data(swap_client, ci, w): wf["mweb_balance"] = w.get("mweb_balance", "?") wf["mweb_pending"] = w.get("mweb_pending", "?") + if hasattr(ci, "getScanStatus"): + wf["scan_status"] = ci.getScanStatus() + + if connection_type == "electrum" and hasattr(ci, "_backend") and ci._backend: + backend = ci._backend + wf["electrum_server"] = backend.getServerHost() + wf["electrum_version"] = backend.getServerVersion() + try: + conn_status = backend.getConnectionStatus() + wf["electrum_connected"] = conn_status.get("connected", False) + wf["electrum_failures"] = conn_status.get("failures", 0) + wf["electrum_using_defaults"] = conn_status.get("using_defaults", True) + wf["electrum_all_failed"] = conn_status.get("all_failed", False) + wf["electrum_last_error"] = conn_status.get("last_error") + if conn_status.get("connected"): + wf["electrum_status"] = "connected" + elif conn_status.get("all_failed"): + wf["electrum_status"] = "all_failed" + else: + wf["electrum_status"] = "disconnected" + except Exception: + wf["electrum_connected"] = False + wf["electrum_status"] = "error" + checkAddressesOwned(swap_client, ci, wf) return wf +def format_transactions(ci, transactions, coin_id): + formatted_txs = [] + + if coin_id in (Coins.XMR, Coins.WOW): + for tx in transactions: + tx_type = tx.get("type", "") + direction = ( + "Incoming" + if tx_type == "in" + else "Outgoing" if tx_type == "out" else tx_type.capitalize() + ) + + formatted_txs.append( + { + "txid": tx.get("txid", ""), + "type": direction, + "amount": ci.format_amount(tx.get("amount", 0)), + "confirmations": tx.get("confirmations", 0), + "timestamp": format_timestamp(tx.get("timestamp", 0)), + "height": tx.get("height", 0), + } + ) + else: + for tx in transactions: + category = tx.get("category", "") + if category == "send": + direction = "Outgoing" + amount = abs(tx.get("amount", 0)) + elif category == "receive": + direction = "Incoming" + amount = tx.get("amount", 0) + else: + direction = category.capitalize() + amount = abs(tx.get("amount", 0)) + + formatted_txs.append( + { + "txid": tx.get("txid", ""), + "type": direction, + "amount": ci.format_amount(ci.make_int(amount)), + "confirmations": tx.get("confirmations", 0), + "timestamp": format_timestamp( + tx.get("time", tx.get("timereceived", 0)) + ), + "address": tx.get("address", ""), + } + ) + + return formatted_txs + + def page_wallets(self, url_split, post_string): server = self.server swap_client = server.swap_client @@ -131,6 +214,7 @@ def page_wallets(self, url_split, post_string): "err_messages": err_messages, "wallets": wallets_formatted, "summary": summary, + "use_tor": getattr(swap_client, "use_tor_proxy", False), }, ) @@ -151,8 +235,25 @@ def page_wallet(self, url_split, post_string): show_utxo_groups: bool = False withdrawal_successful: bool = False force_refresh: bool = False + + tx_filters = { + "page_no": 1, + "limit": 30, + "offset": 0, + } + form_data = self.checkForm(post_string, "wallet", err_messages) if form_data: + if have_data_entry(form_data, "pageback"): + tx_filters["page_no"] = int(form_data[b"pageno"][0]) - 1 + if tx_filters["page_no"] < 1: + tx_filters["page_no"] = 1 + elif have_data_entry(form_data, "pageforwards"): + tx_filters["page_no"] = int(form_data[b"pageno"][0]) + 1 + + if tx_filters["page_no"] > 1: + tx_filters["offset"] = (tx_filters["page_no"] - 1) * 30 + cid = str(int(coin_id)) estimate_fee: bool = have_data_entry(form_data, "estfee_" + cid) @@ -170,6 +271,22 @@ def page_wallet(self, url_split, post_string): except Exception as ex: err_messages.append("Reseed failed " + str(ex)) swap_client.updateWalletsInfo(True, coin_id) + elif have_data_entry(form_data, "importkey_" + cid): + try: + wif_key = form_data[bytes("wifkey_" + cid, "utf-8")][0].decode("utf-8") + if wif_key: + result = swap_client.importWIFKey(coin_id, wif_key) + if result.get("success"): + messages.append( + f"Imported key for address: {result['address']}" + ) + else: + err_messages.append(f"Import failed: {result.get('error')}") + else: + err_messages.append("Missing WIF key") + except Exception as ex: + err_messages.append(f"Import failed: {ex}") + swap_client.updateWalletsInfo(True, coin_id) elif withdraw or estimate_fee: subfee = True if have_data_entry(form_data, "subfee_" + cid) else False page_data["wd_subfee_" + cid] = subfee @@ -215,7 +332,14 @@ def page_wallet(self, url_split, post_string): ].decode("utf-8") page_data["wd_type_from_" + cid] = type_from except Exception as e: # noqa: F841 - err_messages.append("Missing type") + if ( + swap_client.coin_clients[coin_id].get("connection_type") + == "electrum" + ): + type_from = "plain" + page_data["wd_type_from_" + cid] = type_from + else: + err_messages.append("Missing type") if len(err_messages) == 0: ci = swap_client.ci(coin_id) @@ -328,6 +452,9 @@ def page_wallet(self, url_split, post_string): cid = str(int(coin_id)) wallet_data = format_wallet_data(swap_client, ci, w) + wallet_data["is_electrum_mode"] = ( + getattr(ci, "_connection_type", "rpc") == "electrum" + ) fee_rate, fee_src = swap_client.getFeeRateForCoin(k) est_fee = swap_client.estimateWithdrawFee(k, fee_rate) @@ -400,6 +527,26 @@ def page_wallet(self, url_split, post_string): "coin_name": wallet_data.get("name", ticker), } + transactions = [] + total_transactions = 0 + is_electrum_mode = False + if wallet_data.get("havedata", False) and not wallet_data.get("error"): + try: + ci = swap_client.ci(coin_id) + is_electrum_mode = getattr(ci, "_connection_type", "rpc") == "electrum" + if not is_electrum_mode: + count = tx_filters.get("limit", 30) + skip = tx_filters.get("offset", 0) + + all_txs = ci.listWalletTransactions(count=10000, skip=0) + all_txs = list(reversed(all_txs)) if all_txs else [] + total_transactions = len(all_txs) + + raw_txs = all_txs[skip : skip + count] if all_txs else [] + transactions = format_transactions(ci, raw_txs, coin_id) + except Exception as e: + swap_client.log.warning(f"Failed to fetch transactions for {ticker}: {e}") + template = server.env.get_template("wallet.html") return self.render_template( template, @@ -411,5 +558,11 @@ def page_wallet(self, url_split, post_string): "block_unknown_seeds": swap_client._restrict_unknown_seed_wallets, "donation_info": donation_info, "debug_ui": swap_client.debug_ui, + "transactions": transactions, + "tx_page_no": tx_filters.get("page_no", 1), + "tx_total": total_transactions, + "tx_limit": tx_filters.get("limit", 30), + "is_electrum_mode": is_electrum_mode, + "use_tor": getattr(swap_client, "use_tor_proxy", False), }, ) diff --git a/basicswap/ui/util.py b/basicswap/ui/util.py index b935c8f..17670da 100644 --- a/basicswap/ui/util.py +++ b/basicswap/ui/util.py @@ -331,6 +331,7 @@ def describeBid( "ticker_from": ci_from.ticker(), "ticker_to": ci_to.ticker(), "bid_state": strBidState(bid.state), + "bid_state_ind": int(bid.state), "state_description": state_description, "itx_state": strTxState(bid.getITxState()), "ptx_state": strTxState(bid.getPTxState()), @@ -343,6 +344,8 @@ def describeBid( if for_api else format_timestamp(bid.created_at, with_seconds=True) ), + "created_at_timestamp": bid.created_at, + "state_time_timestamp": getLastStateTimestamp(bid), "expired_at": ( bid.expire_at if for_api @@ -623,6 +626,14 @@ def listOldBidStates(bid): return old_states +def getLastStateTimestamp(bid): + if not bid.states or len(bid.states) < 12: + return None + num_states = len(bid.states) // 12 + last_entry = struct.unpack_from(" str: return rv -def format_timestamp(value: int, with_seconds: bool = False) -> str: +def format_timestamp( + value: int, with_seconds: bool = False, with_timezone: bool = False +) -> str: str_format = "%Y-%m-%d %H:%M" if with_seconds: str_format += ":%S" - str_format += " %z" + if with_timezone: + str_format += " %z" return time.strftime(str_format, time.localtime(value)) diff --git a/basicswap/wallet_backend.py b/basicswap/wallet_backend.py new file mode 100644 index 0000000..d5a0281 --- /dev/null +++ b/basicswap/wallet_backend.py @@ -0,0 +1,752 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024-2026 The Basicswap developers +# Distributed under the MIT software license, see the accompanying +# file LICENSE or http://www.opensource.org/licenses/mit-license.php. + +import time +from abc import ABC, abstractmethod +from typing import Dict, List, Optional + + +class WalletBackend(ABC): + + @abstractmethod + def getBalance(self, addresses: List[str]) -> Dict[str, int]: + pass + + def findAddressWithBalance( + self, addresses: List[str], min_balance: int + ) -> Optional[tuple]: + balances = self.getBalance(addresses) + for addr, balance in balances.items(): + if balance >= min_balance: + return (addr, balance) + return None + + @abstractmethod + def getUnspentOutputs( + self, addresses: List[str], min_confirmations: int = 0 + ) -> List[dict]: + pass + + @abstractmethod + def broadcastTransaction(self, tx_hex: str) -> str: + pass + + @abstractmethod + def getTransaction(self, txid: str) -> Optional[dict]: + pass + + @abstractmethod + def getTransactionRaw(self, txid: str) -> Optional[str]: + pass + + @abstractmethod + def getBlockHeight(self) -> int: + pass + + @abstractmethod + def estimateFee(self, blocks: int = 6) -> int: + pass + + @abstractmethod + def isConnected(self) -> bool: + pass + + @abstractmethod + def getAddressHistory(self, address: str) -> List[dict]: + pass + + +class FullNodeBackend(WalletBackend): + + def __init__(self, rpc_client, coin_type, log): + self._rpc = rpc_client + self._coin_type = coin_type + self._log = log + + def getBalance(self, addresses: List[str]) -> Dict[str, int]: + result = {} + for addr in addresses: + result[addr] = 0 + + try: + utxos = self._rpc("listunspent", [0, 9999999, addresses]) + for utxo in utxos: + addr = utxo.get("address") + if addr in result: + result[addr] += int(utxo.get("amount", 0) * 1e8) + except Exception as e: + self._log.warning(f"FullNodeBackend.getBalance error: {e}") + + return result + + def getUnspentOutputs( + self, addresses: List[str], min_confirmations: int = 0 + ) -> List[dict]: + try: + utxos = self._rpc("listunspent", [min_confirmations, 9999999, addresses]) + result = [] + for utxo in utxos: + result.append( + { + "txid": utxo.get("txid"), + "vout": utxo.get("vout"), + "value": int(utxo.get("amount", 0) * 1e8), + "address": utxo.get("address"), + "confirmations": utxo.get("confirmations", 0), + "scriptPubKey": utxo.get("scriptPubKey"), + } + ) + return result + except Exception as e: + self._log.warning(f"FullNodeBackend.getUnspentOutputs error: {e}") + return [] + + def broadcastTransaction(self, tx_hex: str) -> str: + return self._rpc("sendrawtransaction", [tx_hex]) + + def getTransaction(self, txid: str) -> Optional[dict]: + try: + return self._rpc("getrawtransaction", [txid, True]) + except Exception: + return None + + def getTransactionRaw(self, txid: str) -> Optional[str]: + try: + return self._rpc("getrawtransaction", [txid, False]) + except Exception: + return None + + def getBlockHeight(self) -> int: + return self._rpc("getblockcount") + + def estimateFee(self, blocks: int = 6) -> int: + try: + result = self._rpc("estimatesmartfee", [blocks]) + if "feerate" in result: + return int(result["feerate"] * 1e8 / 1000) + return 1 + except Exception: + return 1 + + def isConnected(self) -> bool: + try: + self._rpc("getblockchaininfo") + return True + except Exception: + return False + + def getAddressHistory(self, address: str) -> List[dict]: + return [] + + def importAddress(self, address: str, label: str = "", rescan: bool = False): + try: + self._rpc("importaddress", [address, label, rescan]) + except Exception as e: + if "already in wallet" not in str(e).lower(): + raise + + +class ElectrumBackend(WalletBackend): + + def __init__( + self, + coin_type, + log, + clearnet_servers=None, + onion_servers=None, + chain="mainnet", + proxy_host=None, + proxy_port=None, + ): + from basicswap.interface.electrumx import ElectrumServer + from basicswap.chainparams import Coins, chainparams + + self._coin_type = coin_type + self._log = log + self._subscribed_scripthashes = set() + + coin_params = chainparams.get(coin_type, chainparams.get(Coins.BTC)) + self._network_params = coin_params.get(chain, coin_params.get("mainnet", {})) + + coin_name_map = { + Coins.BTC: "bitcoin", + Coins.LTC: "litecoin", + } + coin_name = coin_name_map.get(coin_type, "bitcoin") + + self._host = "localhost" + self._port = 50002 + self._use_ssl = True + + self._server = ElectrumServer( + coin_name, + clearnet_servers=clearnet_servers, + onion_servers=onion_servers, + log=log, + proxy_host=proxy_host, + proxy_port=proxy_port, + ) + + self._realtime_callback = None + self._address_to_scripthash = {} + + self._cached_height = 0 + self._cached_height_time = 0 + self._height_cache_ttl = 5 + + self._max_batch_size = 10 + self._background_mode = False + + def setBackgroundMode(self, enabled: bool): + self._background_mode = enabled + + def _call(self, method: str, params: list = None, timeout: int = 10): + if self._background_mode and hasattr(self._server, "call_background"): + return self._server.call_background(method, params, timeout) + return self._server.call(method, params, timeout) + + def _call_batch(self, calls: list, timeout: int = 15): + if self._background_mode and hasattr(self._server, "call_batch_background"): + return self._server.call_batch_background(calls, timeout) + return self._server.call_batch(calls, timeout) + + def _split_batch_call( + self, scripthashes: list, method: str, batch_size: int = None + ) -> list: + if batch_size is None: + batch_size = self._max_batch_size + + all_results = [] + for i in range(0, len(scripthashes), batch_size): + chunk = scripthashes[i : i + batch_size] + try: + calls = [(method, [sh]) for sh in chunk] + results = self._call_batch(calls) + all_results.extend(results) + except Exception as e: + self._log.debug(f"Batch chunk failed ({len(chunk)} items): {e}") + for sh in chunk: + try: + result = self._call(method, [sh]) + all_results.append(result) + except Exception as e2: + self._log.debug(f"Individual call failed for {sh[:8]}...: {e2}") + all_results.append(None) + return all_results + + def _isUnsupportedAddress(self, address: str) -> bool: + if address.startswith("ltcmweb1"): + return True + return False + + def _addressToScripthash(self, address: str) -> str: + from basicswap.interface.electrumx import scripthash_from_address + + return scripthash_from_address(address, self._network_params) + + def getBalance(self, addresses: List[str]) -> Dict[str, int]: + result = {} + for addr in addresses: + result[addr] = 0 + + if not addresses: + return result + + addr_list = [addr for addr in addresses if not self._isUnsupportedAddress(addr)] + if not addr_list: + return result + + addr_to_scripthash = {} + for addr in addr_list: + try: + addr_to_scripthash[addr] = self._addressToScripthash(addr) + except Exception as e: + self._log.debug(f"getBalance: scripthash error for {addr[:10]}...: {e}") + + if not addr_to_scripthash: + return result + + scripthashes = list(addr_to_scripthash.values()) + scripthash_to_addr = {v: k for k, v in addr_to_scripthash.items()} + + batch_results = self._split_batch_call( + scripthashes, "blockchain.scripthash.get_balance" + ) + + for i, balance in enumerate(batch_results): + if balance and isinstance(balance, dict): + addr = scripthash_to_addr.get(scripthashes[i]) + if addr: + confirmed = balance.get("confirmed", 0) + unconfirmed = balance.get("unconfirmed", 0) + result[addr] = confirmed + unconfirmed + + return result + + def getDetailedBalance(self, addresses: List[str]) -> Dict[str, dict]: + result = {} + for addr in addresses: + result[addr] = {"confirmed": 0, "unconfirmed": 0} + + if not addresses: + return result + + addr_list = [addr for addr in addresses if not self._isUnsupportedAddress(addr)] + if not addr_list: + return result + + batch_size = 10 + for batch_start in range(0, len(addr_list), batch_size): + batch = addr_list[batch_start : batch_start + batch_size] + + addr_to_scripthash = {} + for addr in batch: + try: + addr_to_scripthash[addr] = self._addressToScripthash(addr) + except Exception as e: + self._log.debug( + f"getDetailedBalance: scripthash error for {addr[:10]}...: {e}" + ) + + if not addr_to_scripthash: + continue + + scripthashes = list(addr_to_scripthash.values()) + scripthash_to_addr = {v: k for k, v in addr_to_scripthash.items()} + batch_success = False + + for attempt in range(2): + try: + batch_results = self._server.get_balance_batch(scripthashes) + for i, balance in enumerate(batch_results): + if balance and isinstance(balance, dict): + addr = scripthash_to_addr.get(scripthashes[i]) + if addr: + result[addr] = { + "confirmed": balance.get("confirmed", 0), + "unconfirmed": balance.get("unconfirmed", 0), + } + batch_success = True + break + except Exception as e: + if attempt == 0: + self._log.debug( + f"Batch detailed balance query failed, reconnecting: {e}" + ) + try: + self._server.disconnect() + except Exception: + pass + time.sleep(0.5) + else: + self._log.debug( + f"Batch detailed balance query failed after retry, falling back: {e}" + ) + + if not batch_success: + for addr, scripthash in addr_to_scripthash.items(): + try: + balance = self._call( + "blockchain.scripthash.get_balance", [scripthash] + ) + if balance and isinstance(balance, dict): + result[addr] = { + "confirmed": balance.get("confirmed", 0), + "unconfirmed": balance.get("unconfirmed", 0), + } + except Exception as e: + self._log.debug( + f"ElectrumBackend.getDetailedBalance error for {addr[:10]}...: {e}" + ) + + return result + + def findAddressWithBalance( + self, addresses: List[str], min_balance: int + ) -> Optional[tuple]: + if not addresses: + return None + + addr_list = [addr for addr in addresses if not self._isUnsupportedAddress(addr)] + if not addr_list: + return None + + batch_size = 50 + for batch_start in range(0, len(addr_list), batch_size): + batch = addr_list[batch_start : batch_start + batch_size] + + addr_to_scripthash = {} + for addr in batch: + try: + addr_to_scripthash[addr] = self._addressToScripthash(addr) + except Exception: + continue + + if not addr_to_scripthash: + continue + + try: + scripthashes = list(addr_to_scripthash.values()) + batch_results = self._server.get_balance_batch(scripthashes) + scripthash_to_addr = {v: k for k, v in addr_to_scripthash.items()} + + for i, balance in enumerate(batch_results): + if balance and isinstance(balance, dict): + confirmed = balance.get("confirmed", 0) + unconfirmed = balance.get("unconfirmed", 0) + total = confirmed + unconfirmed + if total >= min_balance: + addr = scripthash_to_addr.get(scripthashes[i]) + if addr: + return (addr, total) + except Exception as e: + self._log.debug(f"findAddressWithBalance batch error: {e}") + + return None + + def getUnspentOutputs( + self, addresses: List[str], min_confirmations: int = 0 + ) -> List[dict]: + result = [] + if not addresses: + return result + + try: + current_height = self.getBlockHeight() + + for addr in addresses: + if self._isUnsupportedAddress(addr): + continue + try: + scripthash = self._addressToScripthash(addr) + utxos = self._call( + "blockchain.scripthash.listunspent", [scripthash] + ) + if utxos: + for utxo in utxos: + height = utxo.get("height", 0) + if height <= 0: + confirmations = 0 + else: + confirmations = current_height - height + 1 + + if confirmations >= min_confirmations: + result.append( + { + "txid": utxo.get("tx_hash"), + "vout": utxo.get("tx_pos"), + "value": utxo.get("value", 0), + "address": addr, + "confirmations": confirmations, + } + ) + except Exception as e: + self._log.debug( + f"ElectrumBackend.getUnspentOutputs error for {addr[:10]}...: {e}" + ) + except Exception as e: + self._log.warning(f"ElectrumBackend.getUnspentOutputs error: {e}") + + return result + + def broadcastTransaction(self, tx_hex: str) -> str: + import time + + max_retries = 3 + retry_delay = 0.5 + + for attempt in range(max_retries): + try: + result = self._server.call("blockchain.transaction.broadcast", [tx_hex]) + if result: + return result + except Exception as e: + error_msg = str(e).lower() + if any( + pattern in error_msg + for pattern in [ + "missing inputs", + "bad-txns", + "txn-mempool-conflict", + "already in block chain", + "transaction already exists", + "insufficient fee", + "dust", + ] + ): + raise + if attempt < max_retries - 1: + self._log.debug( + f"broadcastTransaction retry {attempt + 1}/{max_retries}: {e}" + ) + time.sleep(retry_delay * (2**attempt)) # Exponential backoff + continue + raise + return None + + def getTransaction(self, txid: str) -> Optional[dict]: + try: + return self._call("blockchain.transaction.get", [txid, True]) + except Exception: + return None + + def getTransactionRaw(self, txid: str) -> Optional[str]: + try: + tx_hex = self._call("blockchain.transaction.get", [txid, False]) + return tx_hex + except Exception as e: + self._log.warning(f"getTransactionRaw failed for {txid[:16]}...: {e}") + return None + + def getTransactionBatch(self, txids: List[str]) -> Dict[str, Optional[dict]]: + result = {} + if not txids: + return result + + try: + calls = [("blockchain.transaction.get", [txid, True]) for txid in txids] + responses = self._call_batch(calls) + for txid, tx_info in zip(txids, responses): + result[txid] = tx_info if tx_info else None + except Exception as e: + self._log.debug(f"getTransactionBatch error: {e}") + for txid in txids: + result[txid] = self.getTransaction(txid) + + return result + + def getTransactionBatchRaw(self, txids: List[str]) -> Dict[str, Optional[str]]: + result = {} + if not txids: + return result + + try: + calls = [("blockchain.transaction.get", [txid, False]) for txid in txids] + responses = self._call_batch(calls) + for txid, tx_hex in zip(txids, responses): + result[txid] = tx_hex if tx_hex else None + except Exception as e: + self._log.debug(f"getTransactionBatchRaw error: {e}") + for txid in txids: + result[txid] = self.getTransactionRaw(txid) + + return result + + def getBlockHeight(self) -> int: + import time + + if hasattr(self._server, "get_subscribed_height"): + subscribed_height = self._server.get_subscribed_height() + if subscribed_height > 0: + if subscribed_height > self._cached_height: + self._cached_height = subscribed_height + self._cached_height_time = time.time() + return subscribed_height + + now = time.time() + if ( + self._cached_height > 0 + and (now - self._cached_height_time) < self._height_cache_ttl + ): + return self._cached_height + + try: + header = self._call("blockchain.headers.subscribe", []) + if header: + height = header.get("height", 0) + if height > 0: + self._cached_height = height + self._cached_height_time = now + return height + return self._cached_height if self._cached_height > 0 else 0 + except Exception: + return self._cached_height if self._cached_height > 0 else 0 + + def estimateFee(self, blocks: int = 6) -> int: + try: + fee = self._call("blockchain.estimatefee", [blocks]) + if fee and fee > 0: + return int(fee * 1e8 / 1000) + return 1 + except Exception: + return 1 + + def isConnected(self) -> bool: + try: + self._call("server.ping", []) + return True + except Exception: + return False + + def getServerVersion(self) -> str: + version = self._server.get_server_version() + if not version: + try: + self._call("server.ping", []) + version = self._server.get_server_version() + except Exception: + pass + return version or "electrum" + + def getServerHost(self) -> str: + host, port = self._server.get_current_server() + if host and port: + return f"{host}:{port}" + return f"{self._host}:{self._port}" + + def getConnectionStatus(self) -> dict: + if hasattr(self._server, "getConnectionStatus"): + status = self._server.getConnectionStatus() + else: + status = { + "connected": self.isConnected(), + "failures": 0, + "last_error": None, + "all_failed": False, + "using_defaults": True, + "server_count": 1, + } + status["server"] = self.getServerHost() + status["version"] = self.getServerVersion() + return status + + def getAddressHistory(self, address: str) -> List[dict]: + if self._isUnsupportedAddress(address): + return [] + try: + scripthash = self._addressToScripthash(address) + history = self._call("blockchain.scripthash.get_history", [scripthash]) + if history: + return [ + {"txid": h.get("tx_hash"), "height": h.get("height", 0)} + for h in history + ] + return [] + except Exception: + return [] + + def getAddressHistoryBackground(self, address: str) -> List[dict]: + if self._isUnsupportedAddress(address): + return [] + try: + scripthash = self._addressToScripthash(address) + history = self._server.call_background( + "blockchain.scripthash.get_history", [scripthash] + ) + if history: + return [ + {"txid": h.get("tx_hash"), "height": h.get("height", 0)} + for h in history + ] + return [] + except Exception: + return [] + + def getBatchBalance(self, scripthashes: List[str]) -> Dict[str, int]: + result = {} + for sh in scripthashes: + result[sh] = 0 + + try: + calls = [("blockchain.scripthash.get_balance", [sh]) for sh in scripthashes] + responses = self._call_batch(calls) + for sh, balance in zip(scripthashes, responses): + if balance: + confirmed = balance.get("confirmed", 0) + unconfirmed = balance.get("unconfirmed", 0) + result[sh] = confirmed + unconfirmed + except Exception as e: + self._log.warning(f"ElectrumBackend.getBatchBalance error: {e}") + + return result + + def getBatchUnspent( + self, scripthashes: List[str], min_confirmations: int = 0 + ) -> Dict[str, List[dict]]: + result = {} + for sh in scripthashes: + result[sh] = [] + + try: + current_height = self.getBlockHeight() + + calls = [("blockchain.scripthash.listunspent", [sh]) for sh in scripthashes] + responses = self._call_batch(calls) + for sh, utxos in zip(scripthashes, responses): + if utxos: + for utxo in utxos: + height = utxo.get("height", 0) + if height <= 0: + confirmations = 0 + else: + confirmations = current_height - height + 1 + + if confirmations >= min_confirmations: + result[sh].append( + { + "txid": utxo.get("tx_hash"), + "vout": utxo.get("tx_pos"), + "value": utxo.get("value", 0), + "confirmations": confirmations, + } + ) + except Exception as e: + self._log.warning(f"ElectrumBackend.getBatchUnspent error: {e}") + + return result + + def enableRealtimeNotifications(self, callback) -> None: + self._realtime_callback = callback + self._server.enable_realtime_notifications() + self._log.info(f"Real-time notifications enabled for {self._coin_type}") + + def _create_scripthash_callback(self, scripthash): + + def callback(sh, new_status): + self._handle_scripthash_notification(sh, new_status) + + return callback + + def _handle_scripthash_notification(self, scripthash, new_status): + if not self._realtime_callback: + return + + address = None + for addr, sh in self._address_to_scripthash.items(): + if sh == scripthash: + address = addr + break + + try: + self._realtime_callback( + self._coin_type, address, scripthash, "balance_change" + ) + except Exception as e: + self._log.debug(f"Error in realtime callback: {e}") + + def subscribeAddressWithCallback(self, address: str) -> str: + if self._isUnsupportedAddress(address): + return None + + try: + scripthash = self._addressToScripthash(address) + self._address_to_scripthash[address] = scripthash + + if self._realtime_callback: + status = self._server.subscribe_with_callback( + scripthash, self._create_scripthash_callback(scripthash) + ) + else: + status = self._call("blockchain.scripthash.subscribe", [scripthash]) + + self._subscribed_scripthashes.add(scripthash) + return status + except Exception as e: + self._log.debug(f"Failed to subscribe to {address}: {e}") + return None + + def getServer(self): + return self._server diff --git a/basicswap/wallet_manager.py b/basicswap/wallet_manager.py new file mode 100644 index 0000000..6e1c522 --- /dev/null +++ b/basicswap/wallet_manager.py @@ -0,0 +1,2028 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024-2026 The Basicswap developers +# Distributed under the MIT software license, see the accompanying +# file LICENSE or http://www.opensource.org/licenses/mit-license.php. + +import hashlib +import threading +import time +from typing import Dict, List, Optional, Tuple + +from .chainparams import Coins +from .contrib.test_framework import segwit_addr +from .db_wallet import ( + WalletAddress, + WalletLockedUTXO, + WalletPendingTx, + WalletState, + WalletTxCache, + WalletWatchOnly, +) +from .util.crypto import hash160 +from .util.extkey import ExtKeyPair + + +class WalletManager: + + SUPPORTED_COINS = {Coins.BTC, Coins.LTC} + + BIP84_COIN_TYPES = { + Coins.BTC: 0, + Coins.LTC: 2, + } + + HRP = { + Coins.BTC: {"mainnet": "bc", "testnet": "tb", "regtest": "bcrt"}, + Coins.LTC: {"mainnet": "ltc", "testnet": "tltc", "regtest": "rltc"}, + } + + GAP_LIMIT = 20 + + def __init__(self, swap_client, log): + self._gap_limits: Dict[Coins, int] = {} + self._swap_client = swap_client + self._log = log + self._seed: bytes = None + self._master_keys: Dict[Coins, bytes] = {} + self._external_chains: Dict[Coins, ExtKeyPair] = {} + self._internal_chains: Dict[Coins, ExtKeyPair] = {} + self._initialized: set = set() + self._migration_in_progress: set = set() + self._balance_sync_lock = threading.Lock() + + def initialize(self, coin_type: Coins, root_key) -> None: + if coin_type not in self.SUPPORTED_COINS: + raise ValueError(f"Coin {coin_type} not supported by WalletManager") + + if isinstance(root_key, ExtKeyPair): + ek = root_key + raw_key = ek._key if hasattr(ek, "_key") else None + self._master_keys[coin_type] = raw_key + if self._seed is None and raw_key: + self._seed = raw_key + elif isinstance(root_key, bytes): + self._master_keys[coin_type] = root_key + if self._seed is None: + self._seed = root_key + ek = ExtKeyPair() + ek.set_seed(root_key) + else: + raise ValueError( + f"root_key must be bytes or ExtKeyPair, got {type(root_key)}" + ) + + bip44_coin = self.BIP84_COIN_TYPES.get(coin_type, 0) + ext_path = f"84h/{bip44_coin}h/0h/0" + int_path = f"84h/{bip44_coin}h/0h/1" + + self._external_chains[coin_type] = ek.derive_path(ext_path) + self._internal_chains[coin_type] = ek.derive_path(int_path) + self._initialized.add(coin_type) + + if self.needsMigration(coin_type): + self.runMigration(coin_type) + + self._log.debug(f"WalletManager: {Coins(coin_type).name} initialized") + + def getDepositAddress(self, coin_type: Coins) -> Optional[str]: + return self.getAddress(coin_type, index=0, internal=False) + + def isInitialized(self, coin_type: Coins) -> bool: + return coin_type in self._initialized + + def _getHRP(self, coin_type: Coins) -> str: + return self.HRP.get(coin_type, {}).get(self._swap_client.chain, "bc") + + def _deriveKey(self, coin_type: Coins, index: int, internal: bool = False) -> bytes: + chain = ( + self._internal_chains[coin_type] + if internal + else self._external_chains[coin_type] + ) + return chain.derive(index)._key + + def _deriveAddress( + self, coin_type: Coins, index: int, internal: bool = False + ) -> Tuple[str, str, bytes]: + from coincurve import PublicKey + + key = self._deriveKey(coin_type, index, internal) + pubkey = PublicKey.from_secret(key).format() + pkh = hash160(pubkey) + address = segwit_addr.encode(self._getHRP(coin_type), 0, pkh) + scripthash = hashlib.sha256(bytes([0x00, 0x14]) + pkh).digest()[::-1].hex() + return address, scripthash, pubkey + + def _syncStateIndices(self, coin_type: Coins, cursor) -> None: + query = "SELECT MAX(derivation_index) FROM wallet_addresses WHERE coin_type = ? AND is_internal = ?" + cursor.execute(query, (int(coin_type), False)) + max_ext = cursor.fetchone()[0] + cursor.execute(query, (int(coin_type), True)) + max_int = cursor.fetchone()[0] + + cursor.execute( + "SELECT last_external_index, last_internal_index FROM wallet_state WHERE coin_type = ?", + (int(coin_type),), + ) + row = cursor.fetchone() + if row is None: + return + + current_ext = row[0] or 0 + current_int = row[1] or 0 + + new_ext = max(current_ext, max_ext) if max_ext is not None else current_ext + new_int = max(current_int, max_int) if max_int is not None else current_int + + if new_ext > current_ext or new_int > current_int: + now = int(time.time()) + cursor.execute( + "UPDATE wallet_state SET last_external_index = ?, last_internal_index = ?, updated_at = ? WHERE coin_type = ?", + (new_ext, new_int, now, int(coin_type)), + ) + self._swap_client.commitDB() + + def getNewAddress( + self, coin_type: Coins, internal: bool = False, label: str = "", cursor=None + ) -> str: + if not self.isInitialized(coin_type): + raise ValueError(f"Wallet not initialized for {Coins(coin_type).name}") + + use_cursor = self._swap_client.openDB(cursor) + try: + self._syncStateIndices(coin_type, use_cursor) + + state = self._swap_client.queryOne( + WalletState, use_cursor, {"coin_type": int(coin_type)} + ) + + if state is None: + next_index = 0 + now = int(time.time()) + self._swap_client.add( + WalletState( + coin_type=int(coin_type), + last_external_index=0, + last_internal_index=0, + derivation_path_type="bip84", + migration_complete=False, + created_at=now, + updated_at=now, + ), + use_cursor, + ) + else: + if internal: + next_index = (state.last_internal_index or 0) + 1 + else: + next_index = (state.last_external_index or 0) + 1 + + existing = self._swap_client.queryOne( + WalletAddress, + use_cursor, + { + "coin_type": int(coin_type), + "derivation_index": next_index, + "is_internal": internal, + }, + ) + + if existing: + address = existing.address + if state: + if internal: + state.last_internal_index = next_index + else: + state.last_external_index = next_index + state.updated_at = int(time.time()) + self._swap_client.updateDB( + state, use_cursor, constraints=["coin_type"] + ) + self._swap_client.commitDB() + return address + + address, scripthash, pubkey = self._deriveAddress( + coin_type, next_index, internal + ) + bip44_coin = self.BIP84_COIN_TYPES.get(coin_type, 0) + chain_idx = 1 if internal else 0 + now = int(time.time()) + self._swap_client.add( + WalletAddress( + coin_type=int(coin_type), + derivation_index=next_index, + is_internal=internal, + derivation_path=f"m/84'/{bip44_coin}'/0'/{chain_idx}/{next_index}", + address=address, + scripthash=scripthash, + pubkey=pubkey, + is_funded=False, + cached_balance=0, + created_at=now, + ), + use_cursor, + ) + + if state: + if internal: + state.last_internal_index = next_index + else: + state.last_external_index = next_index + state.updated_at = now + self._swap_client.updateDB(state, use_cursor, constraints=["coin_type"]) + + self._swap_client.commitDB() + return address + + except Exception as e: + self._swap_client.rollbackDB() + raise e + finally: + if cursor is None: + self._swap_client.closeDB(use_cursor, commit=False) + + def getAddress( + self, coin_type: Coins, index: int, internal: bool = False + ) -> Optional[str]: + import sqlite3 + + try: + conn = sqlite3.connect(self._swap_client.sqlite_file) + cursor = conn.cursor() + cursor.execute( + """SELECT address FROM wallet_addresses + WHERE coin_type = ? AND derivation_index = ? AND is_internal = ?""", + (int(coin_type), index, internal), + ) + row = cursor.fetchone() + conn.close() + return row[0] if row else None + except Exception: + return None + + def getAddressAtIndex( + self, coin_type: Coins, index: int, internal: bool = False + ) -> Optional[str]: + if not self.isInitialized(coin_type): + return None + + try: + address, _, _ = self._deriveAddress(coin_type, index, internal) + return address + except Exception: + return None + + def discoverAddress( + self, coin_type: Coins, address: str, max_index: int = 1000 + ) -> Optional[Tuple[int, bool]]: + if not self.isInitialized(coin_type): + return None + + for internal in [False, True]: + for i in range(max_index): + derived_addr, _, _ = self._deriveAddress(coin_type, i, internal) + if derived_addr == address: + return (i, internal) + return None + + def importAddress( + self, coin_type: Coins, address: str, max_scan_index: int = 1000 + ) -> bool: + if not self.isInitialized(coin_type): + return False + + cursor = self._swap_client.openDB() + try: + existing = self._swap_client.queryOne( + WalletAddress, cursor, {"coin_type": int(coin_type), "address": address} + ) + if existing: + return True + + result = self.discoverAddress(coin_type, address, max_scan_index) + if result is None: + return False + + index, internal = result + existing_at_index = self._swap_client.queryOne( + WalletAddress, + cursor, + { + "coin_type": int(coin_type), + "derivation_index": index, + "is_internal": internal, + }, + ) + if existing_at_index: + return False + + addr, scripthash, pubkey = self._deriveAddress(coin_type, index, internal) + bip44_coin = self.BIP84_COIN_TYPES.get(coin_type, 0) + chain_idx = 1 if internal else 0 + now = int(time.time()) + self._swap_client.add( + WalletAddress( + coin_type=int(coin_type), + derivation_index=index, + is_internal=internal, + derivation_path=f"m/84'/{bip44_coin}'/0'/{chain_idx}/{index}", + address=address, + scripthash=scripthash, + pubkey=pubkey, + is_funded=True, + cached_balance=0, + created_at=now, + ), + cursor, + ) + + state = self._swap_client.queryOne( + WalletState, cursor, {"coin_type": int(coin_type)} + ) + if state: + if internal and (state.last_internal_index or 0) < index: + state.last_internal_index = index + state.updated_at = now + self._swap_client.updateDB(state, cursor, constraints=["coin_type"]) + elif not internal and (state.last_external_index or 0) < index: + state.last_external_index = index + state.updated_at = now + self._swap_client.updateDB(state, cursor, constraints=["coin_type"]) + + self._swap_client.commitDB() + return True + except Exception: + self._swap_client.rollbackDB() + return False + finally: + self._swap_client.closeDB(cursor, commit=False) + + def getAllAddresses( + self, + coin_type: Coins, + include_internal: bool = True, + include_watch_only: bool = True, + funded_only: bool = False, + ) -> List[str]: + import sqlite3 + + try: + conn = sqlite3.connect(self._swap_client.sqlite_file) + cursor = conn.cursor() + query = "SELECT address FROM wallet_addresses WHERE coin_type = ?" + if not include_internal: + query += " AND is_internal = 0" + if funded_only: + query += " AND (is_funded = 1 OR cached_balance > 0)" + query += " ORDER BY derivation_index ASC" + cursor.execute(query, (int(coin_type),)) + addresses = [row[0] for row in cursor.fetchall() if row[0]] + if include_watch_only: + watch_query = ( + "SELECT address FROM wallet_watch_only WHERE coin_type = ?" + ) + if funded_only: + watch_query += " AND (is_funded = 1 OR cached_balance > 0)" + cursor.execute(watch_query, (int(coin_type),)) + addresses.extend( + row[0] + for row in cursor.fetchall() + if row[0] and row[0] not in addresses + ) + conn.close() + return addresses + except Exception: + return [] + + def getFundedAddresses(self, coin_type: Coins) -> Dict[str, str]: + import sqlite3 + + try: + conn = sqlite3.connect(self._swap_client.sqlite_file) + cursor = conn.cursor() + funded = {} + cursor.execute( + "SELECT address, scripthash FROM wallet_addresses WHERE coin_type = ? AND is_funded = 1", + (int(coin_type),), + ) + funded.update( + {row[0]: row[1] for row in cursor.fetchall() if row[0] and row[1]} + ) + cursor.execute( + "SELECT address, scripthash FROM wallet_watch_only WHERE coin_type = ? AND is_funded = 1 AND private_key_encrypted IS NOT NULL AND private_key_encrypted != ''", + (int(coin_type),), + ) + funded.update( + {row[0]: row[1] for row in cursor.fetchall() if row[0] and row[1]} + ) + conn.close() + return funded + except Exception: + return {} + + def getExistingInternalAddress(self, coin_type: Coins) -> Optional[str]: + import sqlite3 + + try: + conn = sqlite3.connect(self._swap_client.sqlite_file) + cursor = conn.cursor() + cursor.execute( + "SELECT address FROM wallet_addresses WHERE coin_type = ? AND is_internal = 1 ORDER BY derivation_index DESC LIMIT 1", + (int(coin_type),), + ) + row = cursor.fetchone() + conn.close() + return row[0] if row and row[0] else None + except Exception: + return None + + def getNewInternalAddress(self, coin_type: Coins) -> Optional[str]: + if coin_type not in self._initialized: + return None + + cursor = None + try: + cursor = self._swap_client.openDB() + + cursor.execute( + "SELECT MAX(derivation_index) FROM wallet_addresses WHERE coin_type = ? AND is_internal = 1", + (int(coin_type),), + ) + row = cursor.fetchone() + next_index = (row[0] or -1) + 1 + + address, scripthash, pubkey = self._deriveAddress( + coin_type, next_index, internal=True + ) + path = f"84h/{self.BIP84_COIN_TYPES.get(coin_type, 0)}h/0h/1/{next_index}" + + now = int(time.time()) + self._swap_client.add( + WalletAddress( + coin_type=int(coin_type), + derivation_index=next_index, + is_internal=True, + derivation_path=path, + address=address, + scripthash=scripthash, + pubkey=pubkey, + is_funded=False, + created_at=now, + ), + cursor, + ) + self._swap_client.commitDB() + + self._log.debug( + f"Generated new internal address for {coin_type.name}: {address[:16]}..." + ) + return address + except Exception as e: + self._log.warning(f"Failed to generate new internal address: {e}") + self._swap_client.rollbackDB() + return None + finally: + if cursor: + self._swap_client.closeDB(cursor, commit=False) + + def getAddressInfo(self, coin_type: Coins, address: str) -> Optional[dict]: + import sqlite3 + + try: + conn = sqlite3.connect(self._swap_client.sqlite_file) + cursor = conn.cursor() + cursor.execute( + "SELECT address, scripthash, derivation_index, is_internal, derivation_path, is_funded, cached_balance FROM wallet_addresses WHERE coin_type = ? AND address = ?", + (int(coin_type), address), + ) + row = cursor.fetchone() + if row: + conn.close() + return { + "address": row[0], + "scripthash": row[1], + "derivation_index": row[2], + "is_internal": bool(row[3]), + "derivation_path": row[4], + "is_funded": bool(row[5]), + "cached_balance": row[6], + "is_watch_only": False, + } + cursor.execute( + "SELECT address, scripthash, is_funded, cached_balance, label FROM wallet_watch_only WHERE coin_type = ? AND address = ?", + (int(coin_type), address), + ) + row = cursor.fetchone() + conn.close() + if row: + return { + "address": row[0], + "scripthash": row[1], + "is_funded": bool(row[2]), + "cached_balance": row[3], + "is_watch_only": True, + "label": row[4], + } + return None + except Exception: + return None + + def getCachedTotalBalance(self, coin_type: Coins) -> int: + import sqlite3 + + try: + conn = sqlite3.connect(self._swap_client.sqlite_file) + cursor = conn.cursor() + + cursor.execute( + "SELECT COALESCE(SUM(cached_balance), 0) FROM wallet_addresses WHERE coin_type = ?", + (int(coin_type),), + ) + total = cursor.fetchone()[0] or 0 + + cursor.execute( + "SELECT COALESCE(SUM(cached_balance), 0) FROM wallet_watch_only WHERE coin_type = ?", + (int(coin_type),), + ) + total += cursor.fetchone()[0] or 0 + + conn.close() + return total + except Exception: + return 0 + + def syncBalances(self, coin_type: Coins, backend, funded_only: bool = False) -> int: + + if not self.isInitialized(coin_type): + return 0 + + if not self._balance_sync_lock.acquire(blocking=False): + self._log.debug( + f"syncBalances: skipping, already in progress for {Coins(coin_type).name}" + ) + return 0 + + try: + addresses = [] + addr_info = {} + cursor = self._swap_client.openDB() + try: + for record in self._swap_client.query( + WalletAddress, cursor, {"coin_type": int(coin_type)} + ): + + if ( + funded_only + and not record.is_funded + and not (record.cached_balance and record.cached_balance > 0) + ): + continue + addresses.append(record.address) + addr_info[record.address] = ( + "wallet", + record.is_internal, + record.derivation_index, + ) + + for record in self._swap_client.query( + WalletWatchOnly, cursor, {"coin_type": int(coin_type)} + ): + if record.address not in addr_info: + if ( + funded_only + and not record.is_funded + and not ( + record.cached_balance and record.cached_balance > 0 + ) + ): + continue + addresses.append(record.address) + addr_info[record.address] = ("watch", False, None) + finally: + self._swap_client.closeDB(cursor, commit=False) + + if not addresses: + return 0 + + try: + balances = backend.getBalance(addresses) + except Exception as e: + self._log.warning(f"syncBalances network error: {e}") + return 0 + + if not balances: + return 0 + + cursor = self._swap_client.openDB() + try: + now = int(time.time()) + updated = 0 + + for addr, balance in balances.items(): + if addr not in addr_info: + continue + + record_type, _, _ = addr_info[addr] + if record_type == "wallet": + record = self._swap_client.queryOne( + WalletAddress, + cursor, + {"coin_type": int(coin_type), "address": addr}, + ) + else: + record = self._swap_client.queryOne( + WalletWatchOnly, + cursor, + {"coin_type": int(coin_type), "address": addr}, + ) + + if record: + old_balance = record.cached_balance or 0 + if balance != old_balance or balance > 0: + record.cached_balance = balance + record.is_funded = balance > 0 + if record_type == "wallet": + record.cached_balance_time = now + self._swap_client.updateDB( + record, cursor, constraints=["coin_type", "address"] + ) + updated += 1 + + self._swap_client.commitDB() + return updated + except Exception as e: + self._log.warning(f"syncBalances DB error: {e}") + self._swap_client.rollbackDB() + return 0 + finally: + self._swap_client.closeDB(cursor, commit=False) + finally: + self._balance_sync_lock.release() + + def findAddressWithCachedBalance( + self, + coin_type: Coins, + min_balance: int, + include_internal: bool = False, + max_cache_age: int = 120, + ) -> Optional[tuple]: + if not self.isInitialized(coin_type): + return None + + import sqlite3 + + now = int(time.time()) + min_cache_time = now - max_cache_age + + try: + conn = sqlite3.connect(self._swap_client.sqlite_file) + cursor = conn.cursor() + + cursor.execute( + """SELECT address, cached_balance, is_internal, cached_balance_time + FROM wallet_addresses + WHERE coin_type = ? AND is_funded = 1 + AND cached_balance_time >= ? + ORDER BY cached_balance DESC, derivation_index ASC""", + (int(coin_type), min_cache_time), + ) + for row in cursor.fetchall(): + address, cached_balance, is_internal, _ = row + if not include_internal and is_internal: + continue + if cached_balance and cached_balance >= min_balance: + conn.close() + return (address, cached_balance) + + cursor.execute( + """SELECT address, cached_balance, cached_balance_time + FROM wallet_watch_only + WHERE coin_type = ? AND is_funded = 1 + AND cached_balance_time >= ?""", + (int(coin_type), min_cache_time), + ) + for row in cursor.fetchall(): + address, cached_balance, _ = row + if cached_balance and cached_balance >= min_balance: + conn.close() + return (address, cached_balance) + + conn.close() + return None + except Exception: + return None + + def hasCachedBalances(self, coin_type: Coins, max_cache_age: int = 120) -> bool: + import sqlite3 + + try: + conn = sqlite3.connect(self._swap_client.sqlite_file) + cursor = conn.cursor() + cursor.execute( + "SELECT COUNT(*) FROM wallet_addresses WHERE coin_type = ? AND cached_balance_time >= ?", + (int(coin_type), int(time.time()) - max_cache_age), + ) + count = cursor.fetchone()[0] + conn.close() + return count > 0 + except Exception: + return False + + def getPrivateKey(self, coin_type: Coins, address: str) -> Optional[bytes]: + if not self.isInitialized(coin_type): + return None + import sqlite3 + + try: + conn = sqlite3.connect(self._swap_client.sqlite_file) + cursor = conn.cursor() + cursor.execute( + "SELECT derivation_index, is_internal FROM wallet_addresses WHERE coin_type = ? AND address = ?", + (int(coin_type), address), + ) + row = cursor.fetchone() + if row is not None: + conn.close() + return self._deriveKey(coin_type, row[0], bool(row[1])) + cursor.execute( + "SELECT private_key_encrypted FROM wallet_watch_only WHERE coin_type = ? AND address = ?", + (int(coin_type), address), + ) + row = cursor.fetchone() + conn.close() + return ( + self._decryptPrivateKey(row[0], coin_type) if row and row[0] else None + ) + except Exception: + return None + + def getSignableAddresses(self, coin_type: Coins) -> Dict[str, str]: + import sqlite3 + + try: + conn = sqlite3.connect(self._swap_client.sqlite_file) + cursor = conn.cursor() + cursor.execute( + "SELECT address, scripthash FROM wallet_addresses WHERE coin_type = ?", + (int(coin_type),), + ) + result = {row[0]: row[1] for row in cursor.fetchall() if row[0] and row[1]} + conn.close() + return result + except Exception: + return {} + + def importWatchOnlyAddress( + self, + coin_type: Coins, + address: str, + scripthash: str = "", + label: str = "", + source: str = "import", + cursor=None, + ) -> None: + owns_cursor = cursor is None + if owns_cursor: + cursor = self._swap_client.openDB() + try: + if self._swap_client.queryOne( + WalletAddress, cursor, {"coin_type": int(coin_type), "address": address} + ): + return + if self._swap_client.queryOne( + WalletWatchOnly, + cursor, + {"coin_type": int(coin_type), "address": address}, + ): + return + + if not scripthash: + scripthash = self._computeScripthash(coin_type, address) + + self._swap_client.add( + WalletWatchOnly( + coin_type=int(coin_type), + address=address, + scripthash=scripthash, + label=label, + source=source, + is_funded=False, + cached_balance=0, + created_at=int(time.time()), + ), + cursor, + ) + if owns_cursor: + self._swap_client.commitDB() + except Exception as e: + if owns_cursor: + self._swap_client.rollbackDB() + raise e + finally: + if owns_cursor: + self._swap_client.closeDB(cursor, commit=False) + + def importAddressWithKey( + self, + coin_type: Coins, + address: str, + private_key: bytes, + label: str = "", + source: str = "import", + ) -> bool: + from coincurve import PublicKey as CCPublicKey + + try: + pubkey = CCPublicKey.from_secret(private_key).format() + if ( + segwit_addr.encode(self._getHRP(coin_type), 0, hash160(pubkey)) + != address + ): + return False + except Exception: + return False + + cursor = self._swap_client.openDB() + try: + if self._swap_client.queryOne( + WalletAddress, cursor, {"coin_type": int(coin_type), "address": address} + ): + return False + + existing_watch = self._swap_client.queryOne( + WalletWatchOnly, + cursor, + {"coin_type": int(coin_type), "address": address}, + ) + encrypted_key = self._encryptPrivateKey(private_key, coin_type) + + if existing_watch: + cursor.execute( + """UPDATE wallet_watch_only SET private_key_encrypted = ?, label = ?, source = ? + WHERE coin_type = ? AND address = ?""", + ( + encrypted_key, + label or existing_watch.label or "", + source, + int(coin_type), + address, + ), + ) + else: + self._swap_client.add( + WalletWatchOnly( + coin_type=int(coin_type), + address=address, + scripthash=self._computeScripthash(coin_type, address), + label=label, + source=source, + is_funded=False, + cached_balance=0, + private_key_encrypted=encrypted_key, + created_at=int(time.time()), + ), + cursor, + ) + + self._swap_client.commitDB() + return True + except Exception: + self._swap_client.rollbackDB() + return False + finally: + self._swap_client.closeDB(cursor, commit=False) + + def importKeysFromRPC(self, coin_type: Coins, rpc_callback) -> int: + imported = 0 + try: + funded_addresses = set() + + try: + for u in rpc_callback("listunspent", [0]): + if u.get("address"): + funded_addresses.add(u["address"]) + except Exception: + pass + + try: + for t in rpc_callback("listtransactions", ["*", 1000]): + if t.get("address"): + funded_addresses.add(t["address"]) + except Exception: + pass + + try: + for r in rpc_callback("listreceivedbyaddress", [0, True]): + if r.get("address"): + funded_addresses.add(r["address"]) + except Exception: + pass + + try: + labels = rpc_callback("listlabels", []) + for label in labels: + try: + addrs = rpc_callback("getaddressesbylabel", [label]) + if isinstance(addrs, dict): + for addr in addrs.keys(): + funded_addresses.add(addr) + except Exception: + pass + except Exception: + pass + + self._log.debug( + f"importKeysFromRPC: Found {len(funded_addresses)} addresses to check" + ) + + for address in funded_addresses: + if self.getPrivateKey(coin_type, address): + continue + try: + wif = rpc_callback("dumpprivkey", [address]) + if wif: + privkey = self._decodeWIF(wif, coin_type) + if privkey and self.importAddressWithKey( + coin_type, address, privkey, source="rpc_migration" + ): + imported += 1 + except Exception: + pass + + self._log.info( + f"importKeysFromRPC: Imported {imported} keys for {coin_type}" + ) + return imported + except Exception as e: + self._log.warning(f"importKeysFromRPC error: {e}") + return imported + + def _b58decode(self, s: str) -> bytes: + ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + n = 0 + for c in s: + n = n * 58 + ALPHABET.index(c) + result = [] + while n > 0: + result.append(n & 0xFF) + n >>= 8 + pad = len(s) - len(s.lstrip("1")) + return bytes(pad) + bytes(reversed(result)) + + def _b58decode_check(self, s: str) -> bytes: + data = self._b58decode(s) + payload, checksum = data[:-4], data[-4:] + expected = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4] + if checksum != expected: + raise ValueError("Invalid base58 checksum") + return payload + + def _decodeWIF( + self, wif: str, coin_type: Coins = None + ) -> Optional[bytes]: # noqa: ARG002 + try: + decoded = self._b58decode_check(wif) + if len(decoded) == 33: + return decoded[1:] + elif len(decoded) == 34: + return decoded[1:33] + return None + except Exception: + return None + + def _getXorKey(self, coin_type: Coins) -> bytes: + master_key = self._master_keys.get(coin_type) + if master_key is None: + raise ValueError(f"Wallet not initialized for {coin_type}") + return hashlib.sha256(master_key + b"_import_key").digest() + + def _encryptPrivateKey(self, private_key: bytes, coin_type: Coins) -> bytes: + return bytes(a ^ b for a, b in zip(private_key, self._getXorKey(coin_type))) + + def _decryptPrivateKey(self, encrypted_key: bytes, coin_type: Coins) -> bytes: + return bytes(a ^ b for a, b in zip(encrypted_key, self._getXorKey(coin_type))) + + def _computeScripthash(self, coin_type: Coins, address: str) -> str: + _, data = segwit_addr.decode(self._getHRP(coin_type), address) + if data is None: + return "" + return hashlib.sha256(bytes([0x00, 0x14]) + bytes(data)).digest()[::-1].hex() + + def needsMigration(self, coin_type: Coins) -> bool: + cursor = self._swap_client.openDB() + try: + state = self._swap_client.queryOne( + WalletState, cursor, {"coin_type": int(coin_type)} + ) + return state is None or not state.migration_complete + finally: + self._swap_client.closeDB(cursor, commit=False) + + def resetMigration(self, coin_type: Coins) -> None: + cursor = self._swap_client.openDB() + try: + state = self._swap_client.queryOne( + WalletState, cursor, {"coin_type": int(coin_type)} + ) + if state: + state.migration_complete = False + state.updated_at = int(time.time()) + self._swap_client.updateDB(state, cursor, constraints=["coin_type"]) + self._swap_client.commitDB() + finally: + self._swap_client.closeDB(cursor, commit=False) + + def runMigration( + self, + coin_type: Coins, + full_node_addresses: Optional[List[str]] = None, + cached_address: Optional[str] = None, + num_addresses: int = 20, + ) -> int: + if not self.isInitialized(coin_type): + raise ValueError(f"Wallet not initialized for {Coins(coin_type).name}") + self._migration_in_progress.add(coin_type) + try: + bip44_coin = self.BIP84_COIN_TYPES.get(coin_type, 0) + derived_addresses = [] + for internal in [False, True]: + chain_idx = 1 if internal else 0 + for i in range(num_addresses): + address, scripthash, pubkey = self._deriveAddress( + coin_type, i, internal + ) + derived_addresses.append( + { + "index": i, + "internal": internal, + "address": address, + "scripthash": scripthash, + "pubkey": pubkey, + "deriv_path": f"m/84'/{bip44_coin}'/0'/{chain_idx}/{i}", + } + ) + + added = 0 + cursor = self._swap_client.openDB() + try: + now = int(time.time()) + + state = self._swap_client.queryOne( + WalletState, cursor, {"coin_type": int(coin_type)} + ) + if state is None: + self._swap_client.add( + WalletState( + coin_type=int(coin_type), + last_external_index=0, + last_internal_index=0, + derivation_path_type="bip84", + migration_complete=False, + created_at=now, + updated_at=now, + ), + cursor, + ) + + for addr_data in derived_addresses: + existing = self._swap_client.queryOne( + WalletAddress, + cursor, + { + "coin_type": int(coin_type), + "derivation_index": addr_data["index"], + "is_internal": addr_data["internal"], + }, + ) + if existing: + continue + + self._swap_client.add( + WalletAddress( + coin_type=int(coin_type), + derivation_index=addr_data["index"], + is_internal=addr_data["internal"], + derivation_path=addr_data["deriv_path"], + address=addr_data["address"], + scripthash=addr_data["scripthash"], + pubkey=addr_data["pubkey"], + is_funded=False, + cached_balance=0, + created_at=now, + ), + cursor, + ) + added += 1 + + if full_node_addresses: + for addr in full_node_addresses: + self.importWatchOnlyAddress( + coin_type, addr, source="full_node_migration", cursor=cursor + ) + + if cached_address: + self.importWatchOnlyAddress( + coin_type, + cached_address, + source="cached_deposit", + cursor=cursor, + ) + + state = self._swap_client.queryOne( + WalletState, cursor, {"coin_type": int(coin_type)} + ) + if state: + state.migration_complete = True + state.last_external_index = num_addresses - 1 + state.last_internal_index = num_addresses - 1 + state.updated_at = now + self._swap_client.updateDB(state, cursor, constraints=["coin_type"]) + + self._swap_client.commitDB() + return added + except Exception as e: + self._swap_client.rollbackDB() + raise e + finally: + self._swap_client.closeDB(cursor, commit=False) + finally: + self._migration_in_progress.discard(coin_type) + + def getAddressCount(self, coin_type: Coins) -> int: + cursor = self._swap_client.openDB() + try: + return len( + list( + self._swap_client.query( + WalletAddress, cursor, {"coin_type": int(coin_type)} + ) + ) + ) + finally: + self._swap_client.closeDB(cursor, commit=False) + + def getSeedID(self, coin_type: Coins) -> Optional[str]: + from basicswap.contrib.test_framework.script import hash160 + + master_key = self._master_keys.get(coin_type) + if master_key is None: + return None + + ek = ExtKeyPair() + ek.set_seed(master_key) + return hash160(ek.encode_p()).hex() + + def signMessage(self, coin_type: Coins, address: str, message: str) -> bytes: + from coincurve import PrivateKey + + key = self.getPrivateKey(coin_type, address) + if key is None: + raise ValueError(f"Cannot sign: no key for address {address}") + return PrivateKey(key).sign(message.encode("utf-8")) + + def signHash(self, coin_type: Coins, address: str, msg_hash: bytes) -> bytes: + from coincurve import PrivateKey + + key = self.getPrivateKey(coin_type, address) + if key is None: + raise ValueError(f"Cannot sign: no key for address {address}") + return PrivateKey(key).sign(msg_hash, hasher=None) + + def getKeyForAddress( + self, coin_type: Coins, address: str + ) -> Optional[Tuple[bytes, bytes]]: + from coincurve import PublicKey + + key = self.getPrivateKey(coin_type, address) + if key is None: + return None + return (key, PublicKey.from_secret(key).format()) + + def findAddressByScripthash( + self, coin_type: Coins, scripthash: str + ) -> Optional[str]: + import sqlite3 + + try: + conn = sqlite3.connect(self._swap_client.sqlite_file) + cursor = conn.cursor() + cursor.execute( + "SELECT address FROM wallet_addresses WHERE coin_type = ? AND scripthash = ?", + (int(coin_type), scripthash), + ) + row = cursor.fetchone() + if row: + conn.close() + return row[0] + cursor.execute( + "SELECT address FROM wallet_watch_only WHERE coin_type = ? AND scripthash = ?", + (int(coin_type), scripthash), + ) + row = cursor.fetchone() + conn.close() + return row[0] if row else None + except Exception: + return None + + def getAllScripthashes(self, coin_type: Coins) -> List[str]: + import sqlite3 + + try: + conn = sqlite3.connect(self._swap_client.sqlite_file) + cursor = conn.cursor() + scripthashes = [] + for table in ["wallet_addresses", "wallet_watch_only"]: + cursor.execute( + f"SELECT scripthash FROM {table} WHERE coin_type = ? AND scripthash IS NOT NULL", + (int(coin_type),), + ) + scripthashes.extend(row[0] for row in cursor.fetchall() if row[0]) + conn.close() + return scripthashes + except Exception: + return [] + + def ensureAddressesExist(self, coin_type: Coins, count: int = 20) -> int: + if not self.isInitialized(coin_type): + return 0 + + cursor = self._swap_client.openDB() + try: + state = self._swap_client.queryOne( + WalletState, cursor, {"coin_type": int(coin_type)} + ) + if state is None: + return 0 + + current_external = state.last_external_index or 0 + current_internal = state.last_internal_index or 0 + added = 0 + bip44_coin = self.BIP84_COIN_TYPES.get(coin_type, 0) + now = int(time.time()) + + for internal in [False, True]: + current = current_internal if internal else current_external + chain_idx = 1 if internal else 0 + + for i in range(current + 1, current + count + 1): + existing = self._swap_client.queryOne( + WalletAddress, + cursor, + { + "coin_type": int(coin_type), + "derivation_index": i, + "is_internal": internal, + }, + ) + if existing: + continue + + address, scripthash, pubkey = self._deriveAddress( + coin_type, i, internal + ) + self._swap_client.add( + WalletAddress( + coin_type=int(coin_type), + derivation_index=i, + is_internal=internal, + derivation_path=f"m/84'/{bip44_coin}'/0'/{chain_idx}/{i}", + address=address, + scripthash=scripthash, + pubkey=pubkey, + is_funded=False, + cached_balance=0, + created_at=now, + ), + cursor, + ) + added += 1 + + if added > 0: + self._swap_client.commitDB() + return added + except Exception as e: + self._swap_client.rollbackDB() + raise e + finally: + self._swap_client.closeDB(cursor, commit=False) + + def syncAddressesToBackend(self, coin_type: Coins, backend) -> int: + if not hasattr(backend, "importAddress"): + return 0 + cursor = self._swap_client.openDB() + try: + synced = 0 + for record in self._swap_client.query( + WalletAddress, cursor, {"coin_type": int(coin_type)} + ): + try: + backend.importAddress( + record.address, f"bsx_{record.derivation_index}" + ) + synced += 1 + except Exception: + pass + for record in self._swap_client.query( + WalletWatchOnly, cursor, {"coin_type": int(coin_type)} + ): + try: + backend.importAddress(record.address, record.label or "watch_only") + synced += 1 + except Exception: + pass + return synced + finally: + self._swap_client.closeDB(cursor, commit=False) + + def getAddressesForSubscription(self, coin_type: Coins) -> List[Tuple[str, str]]: + cursor = self._swap_client.openDB() + try: + result = [ + (r.address, r.scripthash) + for r in self._swap_client.query( + WalletAddress, + cursor, + {"coin_type": int(coin_type), "is_funded": True}, + ) + ] + state = self._swap_client.queryOne( + WalletState, cursor, {"coin_type": int(coin_type)} + ) + if state: + gap_limit = self.getGapLimit(coin_type) + for internal in [False, True]: + last_idx = ( + state.last_internal_index + if internal + else state.last_external_index + ) or 0 + for i in range( + max(0, last_idx - gap_limit), last_idx + gap_limit + 1 + ): + record = self._swap_client.queryOne( + WalletAddress, + cursor, + { + "coin_type": int(coin_type), + "derivation_index": i, + "is_internal": internal, + }, + ) + if record and not record.is_funded: + result.append((record.address, record.scripthash)) + result.extend( + (r.address, r.scripthash) + for r in self._swap_client.query( + WalletWatchOnly, cursor, {"coin_type": int(coin_type)} + ) + ) + return result + finally: + self._swap_client.closeDB(cursor, commit=False) + + def scanForFundedAddresses( + self, coin_type: Coins, backend, gap_limit: int = None + ) -> int: + if gap_limit is None: + gap_limit = self.getGapLimit(coin_type) + if not self.isInitialized(coin_type): + return 0 + + addresses_to_check = [] + cursor = self._swap_client.openDB() + try: + bip44_coin = self.BIP84_COIN_TYPES.get(coin_type, 0) + now = int(time.time()) + + for internal in [False, True]: + chain_idx = 1 if internal else 0 + for index in range(gap_limit): + record = self._swap_client.queryOne( + WalletAddress, + cursor, + { + "coin_type": int(coin_type), + "derivation_index": index, + "is_internal": internal, + }, + ) + if record is None: + address, scripthash, pubkey = self._deriveAddress( + coin_type, index, internal + ) + record = WalletAddress( + coin_type=int(coin_type), + derivation_index=index, + is_internal=internal, + derivation_path=f"m/84'/{bip44_coin}'/0'/{chain_idx}/{index}", + address=address, + scripthash=scripthash, + pubkey=pubkey, + is_funded=False, + created_at=now, + ) + self._swap_client.add(record, cursor) + + if not record.is_funded: + addresses_to_check.append((record.address, index, internal)) + + self._swap_client.commitDB() + finally: + self._swap_client.closeDB(cursor, commit=False) + + if not addresses_to_check: + return 0 + + funded_addresses = [] + try: + addr_list = [a[0] for a in addresses_to_check] + balances = backend.getBalance(addr_list) + for addr, balance in balances.items(): + if balance > 0: + for check_addr, index, internal in addresses_to_check: + if check_addr == addr: + funded_addresses.append((addr, index, internal, balance)) + break + except Exception: + return 0 + + if not funded_addresses: + return 0 + + cursor = self._swap_client.openDB() + try: + found = 0 + max_ext_index = max_int_index = None + + for addr, index, internal, balance in funded_addresses: + record = self._swap_client.queryOne( + WalletAddress, + cursor, + {"coin_type": int(coin_type), "address": addr}, + ) + if record and not record.is_funded: + record.is_funded = True + record.cached_balance = balance + record.cached_balance_time = int(time.time()) + self._swap_client.updateDB( + record, cursor, constraints=["coin_type", "address"] + ) + found += 1 + + if internal: + max_int_index = max(max_int_index or 0, index) + else: + max_ext_index = max(max_ext_index or 0, index) + + if max_ext_index is not None or max_int_index is not None: + state = self._swap_client.queryOne( + WalletState, cursor, {"coin_type": int(coin_type)} + ) + if state: + if max_ext_index and ( + state.last_external_index is None + or max_ext_index > state.last_external_index + ): + state.last_external_index = max_ext_index + if max_int_index and ( + state.last_internal_index is None + or max_int_index > state.last_internal_index + ): + state.last_internal_index = max_int_index + state.updated_at = int(time.time()) + self._swap_client.updateDB(state, cursor, constraints=["coin_type"]) + + self._swap_client.commitDB() + return found + except Exception: + self._swap_client.rollbackDB() + return 0 + finally: + self._swap_client.closeDB(cursor, commit=False) + + def updateFundedStatus( + self, coin_type: Coins, address: str, is_funded: bool + ) -> bool: + cursor = self._swap_client.openDB() + try: + record = self._swap_client.queryOne( + WalletAddress, cursor, {"coin_type": int(coin_type), "address": address} + ) + if record and record.is_funded != is_funded: + record.is_funded = is_funded + self._swap_client.updateDB( + record, cursor, constraints=["coin_type", "address"] + ) + self._swap_client.commitDB() + return True + return False + finally: + self._swap_client.closeDB(cursor, commit=False) + + def lockUTXO( + self, + coin_type: Coins, + txid: str, + vout: int, + value: int = 0, + address: str = None, + bid_id: bytes = None, + expires_in: int = 3600, + ) -> bool: + """Lock a UTXO to prevent double-spending in concurrent swaps.""" + cursor = self._swap_client.openDB() + try: + existing = self._swap_client.queryOne( + WalletLockedUTXO, + cursor, + {"coin_type": int(coin_type), "txid": txid, "vout": vout}, + ) + if existing: + existing.expires_at = int(time.time()) + expires_in if expires_in else 0 + if bid_id: + existing.bid_id = bid_id + self._swap_client.updateDB( + existing, cursor, constraints=["coin_type", "txid", "vout"] + ) + self._swap_client.commitDB() + return True + + now = int(time.time()) + record = WalletLockedUTXO( + coin_type=int(coin_type), + txid=txid, + vout=vout, + value=value, + address=address, + bid_id=bid_id, + locked_at=now, + expires_at=now + expires_in if expires_in else 0, + ) + self._swap_client.add(record, cursor) + self._swap_client.commitDB() + self._log.debug( + f"Locked UTXO {txid[:16]}...:{vout} for {Coins(coin_type).name}" + ) + return True + except Exception as e: + self._log.warning(f"Failed to lock UTXO {txid[:16]}:{vout}: {e}") + self._swap_client.rollbackDB() + return False + finally: + self._swap_client.closeDB(cursor, commit=False) + + def unlockUTXO(self, coin_type: Coins, txid: str, vout: int) -> bool: + cursor = self._swap_client.openDB() + try: + existing = self._swap_client.queryOne( + WalletLockedUTXO, + cursor, + {"coin_type": int(coin_type), "txid": txid, "vout": vout}, + ) + if existing: + cursor.execute( + "DELETE FROM wallet_locked_utxos WHERE coin_type = ? AND txid = ? AND vout = ?", + (int(coin_type), txid, vout), + ) + self._swap_client.commitDB() + self._log.debug( + f"Unlocked UTXO {txid[:16]}...:{vout} for {Coins(coin_type).name}" + ) + return True + return False + except Exception as e: + self._log.warning(f"Failed to unlock UTXO {txid[:16]}:{vout}: {e}") + self._swap_client.rollbackDB() + return False + finally: + self._swap_client.closeDB(cursor, commit=False) + + def unlockUTXOsForBid(self, coin_type: Coins, bid_id: bytes) -> int: + cursor = self._swap_client.openDB() + try: + locked = self._swap_client.query( + WalletLockedUTXO, + cursor, + {"coin_type": int(coin_type), "bid_id": bid_id}, + ) + count = 0 + for utxo in locked: + cursor.execute( + "DELETE FROM wallet_locked_utxos WHERE coin_type = ? AND txid = ? AND vout = ?", + (int(coin_type), utxo.txid, utxo.vout), + ) + count += 1 + if count > 0: + self._swap_client.commitDB() + self._log.debug( + f"Unlocked {count} UTXOs for bid {bid_id.hex()[:16]}..." + ) + return count + except Exception as e: + self._log.warning(f"Failed to unlock UTXOs for bid: {e}") + self._swap_client.rollbackDB() + return 0 + finally: + self._swap_client.closeDB(cursor, commit=False) + + def isUTXOLocked(self, coin_type: Coins, txid: str, vout: int) -> bool: + cursor = self._swap_client.openDB() + try: + existing = self._swap_client.queryOne( + WalletLockedUTXO, + cursor, + {"coin_type": int(coin_type), "txid": txid, "vout": vout}, + ) + if not existing: + return False + if existing.expires_at and existing.expires_at < int(time.time()): + cursor.execute( + "DELETE FROM wallet_locked_utxos WHERE coin_type = ? AND txid = ? AND vout = ?", + (int(coin_type), txid, vout), + ) + self._swap_client.commitDB() + return False + return True + finally: + self._swap_client.closeDB(cursor, commit=False) + + def getLockedUTXOs(self, coin_type: Coins) -> List[dict]: + cursor = self._swap_client.openDB() + try: + now = int(time.time()) + locked = self._swap_client.query( + WalletLockedUTXO, + cursor, + {"coin_type": int(coin_type)}, + ) + result = [] + expired = [] + for utxo in locked: + if utxo.expires_at and utxo.expires_at < now: + expired.append(utxo) + else: + result.append( + { + "txid": utxo.txid, + "vout": utxo.vout, + "value": utxo.value, + "address": utxo.address, + "bid_id": utxo.bid_id, + "locked_at": utxo.locked_at, + "expires_at": utxo.expires_at, + } + ) + for utxo in expired: + cursor.execute( + "DELETE FROM wallet_locked_utxos WHERE coin_type = ? AND txid = ? AND vout = ?", + (int(coin_type), utxo.txid, utxo.vout), + ) + if expired: + self._swap_client.commitDB() + self._log.debug( + f"Cleaned up {len(expired)} expired UTXO locks for {Coins(coin_type).name}" + ) + return result + finally: + self._swap_client.closeDB(cursor, commit=False) + + def extendLocksForBid( + self, coin_type: Coins, bid_id: bytes, extend_seconds: int = 3600 + ) -> int: + cursor = self._swap_client.openDB() + try: + now = int(time.time()) + locked = self._swap_client.query( + WalletLockedUTXO, + cursor, + {"coin_type": int(coin_type), "bid_id": bid_id}, + ) + count = 0 + for utxo in locked: + new_expiry = now + extend_seconds + if utxo.expires_at < new_expiry: + utxo.expires_at = new_expiry + self._swap_client.updateDB( + utxo, cursor, constraints=["coin_type", "txid", "vout"] + ) + count += 1 + if count > 0: + self._swap_client.commitDB() + self._log.debug( + f"Extended {count} UTXO locks for {Coins(coin_type).name} bid" + ) + return count + except Exception as e: + self._log.warning(f"Failed to extend UTXO locks: {e}") + self._swap_client.rollbackDB() + return 0 + finally: + self._swap_client.closeDB(cursor, commit=False) + + def cleanupExpiredLocks(self, coin_type: Coins = None) -> int: + cursor = self._swap_client.openDB() + try: + now = int(time.time()) + if coin_type: + locked = self._swap_client.query( + WalletLockedUTXO, + cursor, + {"coin_type": int(coin_type)}, + ) + else: + locked = self._swap_client.query(WalletLockedUTXO, cursor, {}) + + count = 0 + for utxo in locked: + if utxo.expires_at and utxo.expires_at < now: + cursor.execute( + "DELETE FROM wallet_locked_utxos WHERE coin_type = ? AND txid = ? AND vout = ?", + (utxo.coin_type, utxo.txid, utxo.vout), + ) + count += 1 + if count > 0: + self._swap_client.commitDB() + self._log.debug(f"Cleaned up {count} expired UTXO locks") + return count + except Exception as e: + self._log.warning(f"Failed to cleanup expired locks: {e}") + self._swap_client.rollbackDB() + return 0 + finally: + self._swap_client.closeDB(cursor, commit=False) + + def getCachedTxConfirmations( + self, coin_type: Coins, txid: str + ) -> Optional[Tuple[int, int]]: + + cursor = self._swap_client.openDB() + try: + now = int(time.time()) + results = self._swap_client.query( + WalletTxCache, + cursor, + {"coin_type": int(coin_type), "txid": txid}, + ) + for cached in results: + if cached.expires_at and cached.expires_at < now: + cursor.execute( + "DELETE FROM wallet_tx_cache WHERE coin_type = ? AND txid = ?", + (int(coin_type), txid), + ) + self._swap_client.commitDB() + return None + return (cached.confirmations, cached.block_height) + return None + finally: + self._swap_client.closeDB(cursor, commit=False) + + def cacheTxConfirmations( + self, + coin_type: Coins, + txid: str, + confirmations: int, + block_height: int = 0, + ttl_seconds: int = 60, + ) -> None: + + cursor = self._swap_client.openDB() + try: + now = int(time.time()) + + if confirmations > 0: + ttl_seconds = max(ttl_seconds, 300) + + existing = self._swap_client.query( + WalletTxCache, + cursor, + {"coin_type": int(coin_type), "txid": txid}, + ) + for cached in existing: + cached.confirmations = confirmations + cached.block_height = block_height + cached.cached_at = now + cached.expires_at = now + ttl_seconds + self._swap_client.updateDB(cached, cursor) + self._swap_client.commitDB() + return + + new_cache = WalletTxCache() + new_cache.coin_type = int(coin_type) + new_cache.txid = txid + new_cache.confirmations = confirmations + new_cache.block_height = block_height + new_cache.cached_at = now + new_cache.expires_at = now + ttl_seconds + self._swap_client.add(new_cache, cursor) + self._swap_client.commitDB() + except Exception as e: + self._log.debug(f"Failed to cache tx confirmations: {e}") + self._swap_client.rollbackDB() + finally: + self._swap_client.closeDB(cursor, commit=False) + + def cleanupExpiredTxCache(self) -> int: + cursor = self._swap_client.openDB() + try: + now = int(time.time()) + cached = self._swap_client.query(WalletTxCache, cursor, {}) + count = 0 + for entry in cached: + if entry.expires_at and entry.expires_at < now: + cursor.execute( + "DELETE FROM wallet_tx_cache WHERE coin_type = ? AND txid = ?", + (entry.coin_type, entry.txid), + ) + count += 1 + if count > 0: + self._swap_client.commitDB() + return count + except Exception as e: + self._log.debug(f"Failed to cleanup tx cache: {e}") + self._swap_client.rollbackDB() + return 0 + finally: + self._swap_client.closeDB(cursor, commit=False) + + def addPendingTx( + self, + coin_type: Coins, + txid: str, + tx_type: str = "outgoing", + amount: int = 0, + fee: int = 0, + addresses: List[str] = None, + bid_id: bytes = None, + ) -> bool: + cursor = self._swap_client.openDB() + try: + existing = self._swap_client.query( + WalletPendingTx, + cursor, + {"coin_type": int(coin_type), "txid": txid}, + ) + for _ in existing: + return False + + import json + + pending = WalletPendingTx() + pending.coin_type = int(coin_type) + pending.txid = txid + pending.tx_type = tx_type + pending.amount = amount + pending.fee = fee + pending.addresses = json.dumps(addresses or []) + pending.bid_id = bid_id + pending.first_seen = int(time.time()) + pending.confirmed_at = 0 + + self._swap_client.add(pending, cursor) + self._swap_client.commitDB() + return True + except Exception as e: + self._log.debug(f"Failed to add pending tx: {e}") + self._swap_client.rollbackDB() + return False + finally: + self._swap_client.closeDB(cursor, commit=False) + + def markTxConfirmed(self, coin_type: Coins, txid: str) -> bool: + cursor = self._swap_client.openDB() + try: + results = self._swap_client.query( + WalletPendingTx, + cursor, + {"coin_type": int(coin_type), "txid": txid}, + ) + for pending in results: + if pending.confirmed_at == 0: + pending.confirmed_at = int(time.time()) + self._swap_client.updateDB(pending, cursor) + self._swap_client.commitDB() + return True + return False + except Exception as e: + self._log.debug(f"Failed to mark tx confirmed: {e}") + self._swap_client.rollbackDB() + return False + finally: + self._swap_client.closeDB(cursor, commit=False) + + def getPendingTxs( + self, coin_type: Coins, include_confirmed: bool = False + ) -> List[dict]: + cursor = self._swap_client.openDB() + try: + import json + + results = self._swap_client.query( + WalletPendingTx, + cursor, + {"coin_type": int(coin_type)}, + ) + pending_list = [] + for pending in results: + if not include_confirmed and pending.confirmed_at > 0: + continue + pending_list.append( + { + "txid": pending.txid, + "tx_type": pending.tx_type, + "amount": pending.amount, + "fee": pending.fee, + "addresses": json.loads(pending.addresses or "[]"), + "bid_id": pending.bid_id, + "first_seen": pending.first_seen, + "confirmed_at": pending.confirmed_at, + } + ) + return pending_list + finally: + self._swap_client.closeDB(cursor, commit=False) + + def cleanupConfirmedTxs(self, max_age_seconds: int = 86400) -> int: + cursor = self._swap_client.openDB() + try: + now = int(time.time()) + cutoff = now - max_age_seconds + results = self._swap_client.query(WalletPendingTx, cursor, {}) + count = 0 + for pending in results: + if pending.confirmed_at > 0 and pending.confirmed_at < cutoff: + cursor.execute( + "DELETE FROM wallet_pending_txs WHERE coin_type = ? AND txid = ?", + (pending.coin_type, pending.txid), + ) + count += 1 + if count > 0: + self._swap_client.commitDB() + return count + except Exception as e: + self._log.debug(f"Failed to cleanup confirmed txs: {e}") + self._swap_client.rollbackDB() + return 0 + finally: + self._swap_client.closeDB(cursor, commit=False) + + def cleanupOldWatchOnlyAddresses( + self, coin_type: Coins = None, max_age_days: int = 30 + ) -> int: + cursor = self._swap_client.openDB() + try: + now = int(time.time()) + cutoff = now - (max_age_days * 86400) + + if coin_type: + results = self._swap_client.query( + WalletWatchOnly, + cursor, + {"coin_type": int(coin_type)}, + ) + else: + results = self._swap_client.query(WalletWatchOnly, cursor, {}) + + count = 0 + for watch in results: + # Only cleanup swap-related addresses + if watch.label not in ("bid", "swap", "offer"): + continue + # Only cleanup if old enough + if watch.created_at and watch.created_at < cutoff: + # Only cleanup if not funded (no balance) + if not watch.is_funded or watch.cached_balance == 0: + cursor.execute( + "DELETE FROM wallet_watch_only WHERE coin_type = ? AND address = ?", + (watch.coin_type, watch.address), + ) + count += 1 + + if count > 0: + self._swap_client.commitDB() + self._log.debug( + f"Cleaned up {count} old watch-only addresses" + + (f" for {Coins(coin_type).name}" if coin_type else "") + ) + return count + except Exception as e: + self._log.debug(f"Failed to cleanup watch-only addresses: {e}") + self._swap_client.rollbackDB() + return 0 + finally: + self._swap_client.closeDB(cursor, commit=False) + + def getWatchOnlyAddressCount(self, coin_type: Coins = None) -> int: + cursor = self._swap_client.openDB() + try: + if coin_type: + results = self._swap_client.query( + WalletWatchOnly, + cursor, + {"coin_type": int(coin_type)}, + ) + else: + results = self._swap_client.query(WalletWatchOnly, cursor, {}) + return sum(1 for _ in results) + finally: + self._swap_client.closeDB(cursor, commit=False) diff --git a/tests/basicswap/test_other.py b/tests/basicswap/test_other.py index 94edaed..8ccf936 100644 --- a/tests/basicswap/test_other.py +++ b/tests/basicswap/test_other.py @@ -676,7 +676,7 @@ class Test(unittest.TestCase): def test_db(self): db_test = DBMethods() db_test.sqlite_file = ":memory:" - db_test.mxDB = threading.Lock() + db_test.mxDB = threading.RLock() cursor = db_test.openDB() try: create_db_(db_test._db_con, logger)