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 = `
+
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 = `