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 1c81601..53bb162 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,17 +229,35 @@ def checkAndNotifyBalanceChange(
cc["cached_balance"] = current_balance
cc["cached_total_balance"] = current_total_balance
cc["cached_unconfirmed"] = current_unconfirmed
- balance_event = {
- "event": "coin_balance_updated",
- "coin": ci.ticker(),
- "height": new_height,
- "trigger": trigger_source,
- }
- swap_client.ws_server.send_message_to_all(json.dumps(balance_event))
- except Exception:
- cc["cached_balance"] = None
- cc["cached_total_balance"] = None
- cc["cached_unconfirmed"] = None
+
+ suppress = False
+ if cached_balance is None or cached_total_balance is None:
+ suppress = True
+ elif hasattr(ci, "getBackend") and ci.useBackend():
+ backend = ci.getBackend()
+ if backend and hasattr(backend, "recentlyReconnected"):
+ if backend.recentlyReconnected(grace_seconds=30):
+ suppress = True
+
+ if suppress:
+ swap_client.log.debug(
+ f"{ci.ticker()} balance cache updated silently (trigger: {trigger_source})"
+ )
+ else:
+ swap_client.log.debug(
+ f"{ci.ticker()} balance updated (trigger: {trigger_source})"
+ )
+ balance_event = {
+ "event": "coin_balance_updated",
+ "coin": ci.ticker(),
+ "height": new_height,
+ "trigger": trigger_source,
+ }
+ swap_client.ws_server.send_message_to_all(json.dumps(balance_event))
+ except Exception as e:
+ swap_client.log.debug(
+ f"checkAndNotifyBalanceChange {ci.ticker()}: balance check failed: {e}"
+ )
def threadPollXMRChainState(swap_client, coin_type):
@@ -288,6 +318,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 +460,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 +469,8 @@ 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._cached_electrum_legacy_funds = {}
self.check_updates_seconds = self.get_int_setting(
"check_updates_seconds", 24 * 60 * 60, 60 * 60, 7 * 24 * 60 * 60
@@ -385,7 +505,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 +579,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
@@ -474,6 +594,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
self.thread_pool = concurrent.futures.ThreadPoolExecutor(
max_workers=4, thread_name_prefix="bsp"
)
+ self._electrum_spend_check_futures = {}
# Encode key to match network
wif_prefix = chainparams[Coins.PART][self.chain]["key_prefix"]
@@ -493,7 +614,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 +674,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 +702,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)
@@ -618,6 +750,23 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
self.delay_event.set()
self.chainstate_delay_event.set()
+ for coin_type, cc in self.coin_clients.items():
+ interface = cc.get("interface")
+ if (
+ interface
+ and hasattr(interface, "_backend")
+ and interface._backend is not None
+ ):
+ try:
+ if hasattr(interface._backend, "_server"):
+ if hasattr(interface._backend._server, "shutdown"):
+ interface._backend._server.shutdown()
+ else:
+ interface._backend._server.disconnect()
+ self.log.debug(f"Disconnected electrum backend for {coin_type}")
+ except Exception as e:
+ self.log.debug(f"Error disconnecting electrum backend: {e}")
+
if self._network:
self._network.stopNetwork()
self._network = None
@@ -626,7 +775,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
for t in self.threads:
if hasattr(t, "stop") and callable(t.stop):
t.stop()
- t.join()
+ t.join(timeout=15)
if sys.version_info[1] >= 9:
self.thread_pool.shutdown(cancel_futures=True)
@@ -747,6 +896,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 +1110,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 +1155,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 +1169,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 +1289,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 +1394,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 +1671,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(
@@ -1455,8 +1692,13 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
def checkSystemStatus(self) -> None:
ci = self.ci(Coins.PART)
- if ci.isWalletLocked():
- raise LockedCoinError(Coins.PART)
+ try:
+ if ci.isWalletLocked():
+ raise LockedCoinError(Coins.PART)
+ except Exception as e:
+ if "not exist or is not loaded" in str(e):
+ raise LockedCoinError(Coins.PART)
+ raise
def checkForUpdates(self) -> None:
if not self.settings.get("check_updates", True):
@@ -1522,7 +1764,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 +1779,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 +1835,1349 @@ 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
+
+ suppress = False
+ if hasattr(ci, "getBackend") and ci.useBackend():
+ backend = ci.getBackend()
+ if backend and hasattr(backend, "recentlyReconnected"):
+ if backend.recentlyReconnected(grace_seconds=30):
+ suppress = True
+
+ if suppress:
+ self.log.debug(
+ f"Electrum notification: {ci.ticker()} balance cache updated silently (recent reconnection)"
+ )
+ else:
+ 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": False,
+ "reason": "has_balance",
+ "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:
+ 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"):
+ pass
+ 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,
+ "ticker": chainparams[coin_type]["ticker"],
+ "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 sweepLiteWalletFunds(self, coin_type: Coins) -> dict:
+ try:
+ coin_name = getCoinName(coin_type)
+ self.log.info(f"Manual sweep requested for {coin_name}")
+
+ if coin_type in WalletManager.SUPPORTED_COINS:
+ if not self._wallet_manager.isInitialized(coin_type):
+ try:
+ self.initializeWalletManager(coin_type)
+ except Exception as e:
+ return {
+ "success": False,
+ "error": f"Wallet locked: {e}",
+ }
+
+ result = self._transferLiteWalletBalanceToRPC(coin_type)
+ if result is None:
+ return {"skipped": True, "reason": "No balance to sweep"}
+ if result.get("skipped"):
+ return result
+ if result.get("txid"):
+ return {
+ "success": True,
+ "txid": result["txid"],
+ "amount": result.get("amount", 0) / 1e8,
+ "fee": result.get("fee", 0) / 1e8,
+ "address": result.get("address", ""),
+ }
+ if result.get("error"):
+ return {"success": False, "error": result["error"]}
+ return {"skipped": True, "reason": "Unknown result"}
+ except Exception as e:
+ self.log.error(
+ f"sweepLiteWalletFunds error for {getCoinName(coin_type)}: {e}"
+ )
+ return {"success": False, "error": str(e)}
+
+ def _consolidateLegacyFundsToSegwit(self, coin_type: Coins) -> dict:
+ try:
+ coin_name = getCoinName(coin_type)
+ cc = self.coin_clients[coin_type]
+ ci = cc.get("interface")
+ if not ci:
+ return {"skipped": True, "reason": "No coin interface"}
+
+ try:
+ unspent = ci.rpc_wallet("listunspent")
+ except Exception as e:
+ return {"error": f"Failed to list UTXOs: {e}"}
+
+ bip84_addresses = set()
+ wm = ci.getWalletManager()
+ if wm:
+ try:
+ all_addrs = wm.getAllAddresses(coin_type, include_watch_only=False)
+ bip84_addresses = set(all_addrs)
+ except Exception as e:
+ self.log.debug(f"Error getting BIP84 addresses: {e}")
+
+ legacy_utxos = []
+ total_legacy_sats = 0
+
+ for u in unspent:
+ if "address" not in u or "txid" not in u:
+ continue
+ if "vout" not in u and "n" not in u:
+ continue
+ addr = u["address"]
+ if addr not in bip84_addresses:
+ if "vout" not in u and "n" in u:
+ u["vout"] = u["n"]
+ legacy_utxos.append(u)
+ total_legacy_sats += ci.make_int(u.get("amount", 0))
+
+ if not legacy_utxos:
+ return {"skipped": True, "reason": "No legacy funds found"}
+
+ if total_legacy_sats <= 0:
+ return {"skipped": True, "reason": "No balance on legacy addresses"}
+
+ est_vsize = len(legacy_utxos) * 150 + 40
+ fee_rate, _ = ci.get_fee_rate(ci._conf_target)
+ if isinstance(fee_rate, int):
+ fee_per_vbyte = max(1, fee_rate // 1000)
+ else:
+ fee_per_vbyte = max(1, int(fee_rate * 100000))
+ estimated_fee_sats = est_vsize * fee_per_vbyte
+
+ if total_legacy_sats <= estimated_fee_sats * 2:
+ return {
+ "skipped": True,
+ "reason": f"Legacy balance ({total_legacy_sats}) too low for fee ({estimated_fee_sats})",
+ }
+
+ new_address = None
+ if wm:
+ try:
+ new_address = wm.getNewAddress(coin_type, internal=False)
+ self.log.info(
+ f"[Consolidate {coin_name}] Using BIP84 address: {new_address}"
+ )
+ except Exception as e:
+ self.log.warning(f"Failed to get BIP84 address: {e}")
+
+ if not new_address:
+ try:
+ new_address = ci.rpc_wallet(
+ "getnewaddress", ["consolidate", "bech32"]
+ )
+ self.log.warning(
+ f"[Consolidate {coin_name}] Using Core address (not BIP84): {new_address}"
+ )
+ except Exception as e:
+ return {"error": f"Failed to get new address: {e}"}
+
+ send_amount_sats = total_legacy_sats - estimated_fee_sats
+ send_amount_btc = ci.format_amount(send_amount_sats)
+
+ self.log.info(
+ f"[Consolidate {coin_name}] Moving {ci.format_amount(total_legacy_sats)} from "
+ f"{len(legacy_utxos)} legacy UTXOs to {new_address}"
+ )
+
+ inputs = [{"txid": u["txid"], "vout": u["vout"]} for u in legacy_utxos]
+
+ try:
+ raw_tx = ci.rpc_wallet(
+ "createrawtransaction",
+ [inputs, {new_address: float(send_amount_btc)}],
+ )
+ except Exception as e:
+ return {"error": f"Failed to create transaction: {e}"}
+
+ try:
+ signed = ci.rpc_wallet("signrawtransactionwithwallet", [raw_tx])
+ if not signed.get("complete"):
+ return {"error": "Failed to sign transaction"}
+ txid = ci.rpc_wallet("sendrawtransaction", [signed["hex"]])
+ except Exception as e:
+ return {"error": f"Failed to broadcast transaction: {e}"}
+
+ self.log.info(f"[Consolidate {coin_name}] SUCCESS! TXID: {txid}")
+
+ return {
+ "success": True,
+ "txid": txid,
+ "amount": send_amount_sats / 1e8,
+ "fee": estimated_fee_sats / 1e8,
+ "address": new_address,
+ "num_inputs": len(legacy_utxos),
+ }
+
+ except Exception as e:
+ self.log.error(f"_consolidateLegacyFundsToSegwit error: {e}")
+ import traceback
+
+ self.log.debug(traceback.format_exc())
+ return {"error": str(e)}
+
+ 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 getElectrumLegacyFundsInfo(self, coin_type: Coins) -> dict:
+ cached = self._cached_electrum_legacy_funds.get(int(coin_type))
+ if cached is not None:
+ return cached
+ return self._computeElectrumLegacyFundsInfo(coin_type)
+
+ def _computeElectrumLegacyFundsInfo(self, coin_type: Coins) -> dict:
+ try:
+ cc = self.coin_clients.get(coin_type)
+ if not cc or cc.get("connection_type") != "electrum":
+ return {"has_legacy_funds": False}
+
+ if not self._wallet_manager:
+ return {"has_legacy_funds": False}
+
+ ci = self.ci(coin_type)
+ hrp = ci.chainparams_network().get("hrp", "bc")
+
+ unspent_by_addr = ci.getUnspentsByAddr()
+ if not unspent_by_addr:
+ return {"has_legacy_funds": False}
+
+ legacy_balance_sats = 0
+ legacy_addresses = []
+
+ for addr, balance_sats in unspent_by_addr.items():
+ if not addr.startswith(hrp + "1"):
+ legacy_balance_sats += balance_sats
+ legacy_addresses.append(addr)
+
+ if legacy_balance_sats > 0:
+ return {
+ "has_legacy_funds": True,
+ "legacy_balance_sats": legacy_balance_sats,
+ "legacy_balance": ci.format_amount(legacy_balance_sats),
+ "legacy_address_count": len(legacy_addresses),
+ "coin": ci.ticker_mainnet(),
+ }
+ return {"has_legacy_funds": False}
+ except Exception as e:
+ self.log.debug(f"_computeElectrumLegacyFundsInfo error: {e}")
+ return {"has_legacy_funds": False}
+
+ 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 +3206,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 +3491,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 +3647,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 +3974,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 +4516,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:
@@ -3002,6 +4645,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)
@@ -3037,6 +4707,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:
@@ -3055,21 +4733,52 @@ 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:
+ 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."
)
@@ -3094,10 +4803,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:
@@ -3122,7 +4836,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
@@ -3179,7 +4892,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)
@@ -5038,7 +6751,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)
@@ -5138,7 +6851,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)
@@ -5175,7 +6888,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():
@@ -5234,7 +6947,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:
@@ -5360,7 +7073,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)
@@ -5660,6 +7373,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")
@@ -5700,7 +7415,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,
@@ -5738,9 +7454,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
)
)
@@ -5810,6 +7526,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
dest_address,
bid.amount_to,
bid.chain_b_height_start,
+ find_index=True,
vout=bid.xmr_b_lock_tx.vout,
)
else:
@@ -5854,6 +7571,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=found_tx.get("index", 0),
)
if bid.xmr_b_lock_tx.txid != found_txid:
self.log.debug(
@@ -5867,6 +7585,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)
@@ -6036,6 +7755,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)
@@ -6263,53 +7988,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)
@@ -6321,12 +8089,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:
@@ -6370,6 +8142,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)
@@ -6857,12 +8632,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)
@@ -6892,6 +8681,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}"
@@ -6979,6 +8775,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:
@@ -7271,6 +9074,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(
@@ -7290,6 +9096,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(
@@ -7399,6 +9208,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
@@ -7515,6 +9327,169 @@ 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 _fetchSpendsElectrum(self, coin_type, watched_outputs, watched_scripts):
+ ci = self.ci(coin_type)
+ results = {"outputs": [], "scripts": [], "chain_blocks": 0}
+
+ try:
+ results["chain_blocks"] = ci.getChainHeight()
+ except Exception as e:
+ self.log.debug(f"_fetchSpendsElectrum getChainHeight error: {e}")
+ return results
+
+ for o in watched_outputs:
+ if self.delay_event.is_set():
+ return results
+ try:
+ spend_info = ci.checkWatchedOutput(o.txid_hex, o.vout)
+ if spend_info:
+ raw_tx = ci.getBackend().getTransactionRaw(spend_info["txid"])
+ if raw_tx:
+ 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)
+ ],
+ }
+ results["outputs"].append((o, spend_info, tx_dict))
+ except Exception as e:
+ self.log.debug(f"_fetchSpendsElectrum checkWatchedOutput error: {e}")
+
+ for s in watched_scripts:
+ if self.delay_event.is_set():
+ return results
+ try:
+ found = ci.checkWatchedScript(s.script)
+ if found:
+ results["scripts"].append((s, found))
+ except Exception as e:
+ self.log.debug(f"_fetchSpendsElectrum checkWatchedScript error: {e}")
+
+ return results
+
+ def _processFetchedSpends(self, coin_type, results):
+ c = self.coin_clients[coin_type]
+
+ for o, spend_info, tx_dict in results["outputs"]:
+ try:
+ self.log.debug(
+ f"Found spend via Electrum {self.logIDT(o.txid_hex)} {o.vout} in {self.logIDT(spend_info['txid'])} {spend_info['vin']}"
+ )
+ self.processSpentOutput(
+ coin_type, o, spend_info["txid"], spend_info["vin"], tx_dict
+ )
+ except Exception as e:
+ self.log.debug(f"_processFetchedSpends output error: {e}")
+
+ for s, found in results["scripts"]:
+ try:
+ 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"_processFetchedSpends script error: {e}")
+
+ chain_blocks = results.get("chain_blocks", 0)
+ if chain_blocks > 0:
+ 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")
@@ -7713,11 +9688,12 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
try:
cursor = self.openDB()
- query = "SELECT action_type, linked_id FROM actions WHERE active_ind = 1 AND trigger_at <= :now"
+ query = "SELECT action_id, action_type, linked_id FROM actions WHERE active_ind = 1 AND trigger_at <= :now"
rows = cursor.execute(query, {"now": now}).fetchall()
+ retry_action_ids = []
for row in rows:
- action_type, linked_id = row
+ action_id, action_type, linked_id = row
accepting_bid: bool = False
try:
if action_type == ActionTypes.ACCEPT_BID:
@@ -7749,6 +9725,11 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
self.acceptADSReverseBid(linked_id, cursor)
else:
self.log.warning(f"Unknown event type: {action_type}")
+ except TemporaryError as ex:
+ self.log.warning(
+ f"checkQueuedActions temporary error for {self.log.id(linked_id)}: {ex}"
+ )
+ retry_action_ids.append(action_id)
except Exception as ex:
err_msg = f"checkQueuedActions failed: {ex}"
self.logException(err_msg)
@@ -7781,10 +9762,23 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
bid.setState(BidStates.BID_ERROR)
self.saveBidInSession(bid_id, bid, cursor)
- query: str = "DELETE FROM actions WHERE trigger_at <= :now"
- if self.debug:
- query = "UPDATE actions SET active_ind = 2 WHERE trigger_at <= :now"
- cursor.execute(query, {"now": now})
+ if retry_action_ids:
+ placeholders = ",".join(
+ f":retry_{i}" for i in range(len(retry_action_ids))
+ )
+ params = {"now": now}
+ for i, aid in enumerate(retry_action_ids):
+ params[f"retry_{i}"] = aid
+ if self.debug:
+ query = f"UPDATE actions SET active_ind = 2 WHERE trigger_at <= :now AND action_id NOT IN ({placeholders})"
+ else:
+ query = f"DELETE FROM actions WHERE trigger_at <= :now AND action_id NOT IN ({placeholders})"
+ cursor.execute(query, params)
+ else:
+ query: str = "DELETE FROM actions WHERE trigger_at <= :now"
+ if self.debug:
+ query = "UPDATE actions SET active_ind = 2 WHERE trigger_at <= :now"
+ cursor.execute(query, {"now": now})
except Exception as ex:
self.handleSessionErrors(ex, cursor, "checkQueuedActions")
@@ -9536,13 +11530,18 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
)
try:
- b_lock_tx_id = ci_to.publishBLockTx(
+ b_lock_vout = 0
+ result = ci_to.publishBLockTx(
xmr_swap.vkbv,
xmr_swap.pkbs,
bid.amount_to,
b_fee_rate,
unlock_time=unlock_time,
)
+ if isinstance(result, tuple):
+ b_lock_tx_id, b_lock_vout = result
+ else:
+ b_lock_tx_id = result
if bid.debug_ind == DebugTypes.B_LOCK_TX_MISSED_SEND:
self.log.debug(
f"Adaptor-sig bid {self.log.id(bid_id)}: Debug {bid.debug_ind} - Losing XMR lock tx {self.log.id(b_lock_tx_id)}."
@@ -9600,6 +11599,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
bid_id=bid_id,
tx_type=TxTypes.XMR_SWAP_B_LOCK,
txid=b_lock_tx_id,
+ vout=b_lock_vout,
)
xmr_swap.b_lock_tx_id = b_lock_tx_id
bid.xmr_b_lock_tx.setState(TxStates.TX_SENT)
@@ -9683,6 +11683,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)
@@ -9796,6 +11804,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)}.")
@@ -10284,6 +12299,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}."
@@ -10985,18 +13004,30 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
if self._zmq_queue_enabled and self.zmqSubscriber:
try:
if self._read_zmq_queue:
- topic, message, seq = self.zmqSubscriber.recv_multipart(
- flags=zmq.NOBLOCK
- )
- if topic == b"smsg":
- self.processZmqSmsg(message)
- elif topic == b"hashwtx":
- self.processZmqHashwtx(message)
+ for _i in range(100):
+ topic, message, seq = self.zmqSubscriber.recv_multipart(
+ flags=zmq.NOBLOCK
+ )
+ if topic == b"smsg":
+ self.processZmqSmsg(message)
+ elif topic == b"hashwtx":
+ self.processZmqHashwtx(message)
except zmq.Again as e: # noqa: F841
pass
except Exception as e:
self.logException(f"smsg zmq {e}")
+ for k, future in list(self._electrum_spend_check_futures.items()):
+ if future.done():
+ try:
+ results = future.result()
+ self._processFetchedSpends(k, results)
+ except Exception as e:
+ self.log.debug(
+ f"Background electrum spend check error for {Coins(k).name}: {e}"
+ )
+ del self._electrum_spend_check_futures[k]
+
self.updateNetwork()
try:
@@ -11030,6 +13061,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:
@@ -11041,7 +13089,21 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
):
continue
if len(c["watched_outputs"]) > 0 or len(c["watched_scripts"]):
- self.checkForSpends(k, c)
+ if c.get("connection_type") == "electrum":
+ if (
+ k not in self._electrum_spend_check_futures
+ or self._electrum_spend_check_futures[k].done()
+ ):
+ self._electrum_spend_check_futures[k] = (
+ self.thread_pool.submit(
+ self._fetchSpendsElectrum,
+ k,
+ list(c["watched_outputs"]),
+ list(c["watched_scripts"]),
+ )
+ )
+ else:
+ self.checkForSpends(k, c)
self._last_checked_watched = now
if now - self._last_checked_expired >= self.check_expired_seconds:
@@ -11051,6 +13113,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}.")
@@ -11118,6 +13188,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}")
@@ -11376,6 +13450,125 @@ 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)."
+ )
+
+ auto_transfer_now = data.get("auto_transfer_now", False)
+ if auto_transfer_now:
+ transfer_result = self._consolidateLegacyFundsToSegwit(
+ coin_id
+ )
+ if transfer_result.get("success"):
+ self.log.info(
+ f"Consolidated {transfer_result.get('amount', 0):.8f} {display_name} "
+ f"from legacy addresses. TXID: {transfer_result.get('txid')}"
+ )
+ migration_message += f" Transferred {transfer_result.get('amount', 0):.8f} {display_name} to native segwit."
+ elif transfer_result.get("skipped"):
+ self.log.info(
+ f"Legacy fund transfer skipped for {coin_name}: {transfer_result.get('reason')}"
+ )
+ elif transfer_result.get("error"):
+ self.log.warning(
+ f"Legacy fund transfer warning for {coin_name}: {transfer_result.get('error')}"
+ )
+ 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):
+ reason = empty_check.get("reason", "")
+
+ auto_transfer_now = data.get("auto_transfer_now", False)
+
+ if reason == "has_balance" and auto_transfer_now:
+ self.log.info(
+ f"Auto-transfer requested for {coin_name} during mode switch"
+ )
+ sweep_result = self.sweepLiteWalletFunds(coin_id)
+ if sweep_result.get("success"):
+ self.log.info(
+ f"Swept {sweep_result.get('amount', 0):.8f} {display_name} "
+ f"to RPC wallet. TXID: {sweep_result.get('txid')}"
+ )
+ migration_message = (
+ f"Transferred {sweep_result.get('amount', 0):.8f} {display_name} "
+ f"to full node wallet."
+ )
+ elif sweep_result.get("skipped"):
+ self.log.info(
+ f"Sweep skipped for {coin_name}: {sweep_result.get('reason')}"
+ )
+ else:
+ error = sweep_result.get("error", "Transfer failed")
+ self.log.error(
+ f"Transfer failed for {coin_name}: {error}"
+ )
+ raise ValueError(f"Transfer failed: {error}")
+ elif reason == "active_swap":
+ error = empty_check.get(
+ "message", "Cannot switch: active swap in progress"
+ )
+ self.log.error(
+ f"Migration blocked for {coin_name}: {error}"
+ )
+ raise ValueError(error)
+ elif reason == "has_balance":
+ balance_msg = empty_check.get("message", "")
+ self.log.warning(
+ f"Switching {coin_name} to RPC without transfer: {balance_msg}"
+ )
+ migration_message = (
+ f"{display_name} has funds on lite wallet addresses. "
+ f"Keypool will be synced and rescan triggered."
+ )
+
+ 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]
@@ -11476,7 +13669,49 @@ 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 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")
@@ -11484,7 +13719,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}.")
@@ -11522,7 +13757,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"]
@@ -11608,8 +13843,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(
@@ -11617,6 +13858,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:
@@ -11640,6 +13886,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:
@@ -11666,17 +13915,39 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
elif coin == Coins.NAV:
rv["immature"] = walletinfo["immature_balance"]
elif coin == Coins.LTC:
+ 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)
+ elif coin == Coins.FIRO:
try:
- rv["mweb_address"] = self.getCachedStealthAddressForCoin(
- Coins.LTC_MWEB
+ rv["spark_address"] = self.getCachedStealthAddressForCoin(
+ Coins.FIRO
)
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"]
+ rv["spark_balance"] = (
+ 0
+ if walletinfo["spark_balance"] == 0
+ else ci.format_amount(walletinfo["spark_balance"])
+ )
+ spark_pending_int = (
+ walletinfo["spark_unconfirmed"] + walletinfo["spark_immature"]
+ )
+ rv["spark_pending"] = (
+ 0 if spark_pending_int == 0 else ci.format_amount(spark_pending_int)
)
elif coin == Coins.FIRO:
try:
@@ -11706,9 +13977,9 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
def addWalletInfoRecord(self, coin, info_type, wi) -> None:
coin_id = int(coin)
+ now: int = self.getTime()
cursor = self.openDB()
try:
- now: int = self.getTime()
self.add(
Wallets(
coin_id=coin,
@@ -11729,19 +14000,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,
@@ -11751,36 +14101,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):
@@ -12069,7 +14481,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))
@@ -12557,15 +14969,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}")
@@ -12574,6 +15010,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:
@@ -12745,6 +15189,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,
@@ -12752,8 +15307,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")
@@ -12807,8 +15360,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")
@@ -12881,8 +15432,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")
@@ -13057,12 +15606,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..5ee6cbd 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):
@@ -628,6 +641,7 @@ def canTimeoutBidState(state):
BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_TX_SIGS,
BidStates.XMR_SWAP_HAVE_SCRIPT_COIN_SPEND_TX,
BidStates.XMR_SWAP_MSG_SCRIPT_LOCK_SPEND_TX,
+ BidStates.BID_REQUEST_ACCEPTED,
)
diff --git a/basicswap/bin/prepare.py b/basicswap/bin/prepare.py
index 3de851d..97cb918 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():
@@ -1834,6 +1841,7 @@ def initialise_wallets(
daemons = []
daemon_args = ["-noconnect", "-nodnsseed"]
generated_mnemonic: bool = False
+ extended_keys = {}
coins_failed_to_initialise = []
@@ -1955,6 +1963,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 +2065,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:
@@ -2082,6 +2099,20 @@ def initialise_wallets(
except Exception as e: # noqa: F841
logger.warning(f"changeWalletPassword failed for {coin_name}.")
+ zprv_prefix = 0x04B2430C if chain == "mainnet" else 0x045F18BC
+ for coin_name in with_coins:
+ c = swap_client.getCoinIdFromName(coin_name)
+ if c == Coins.PART:
+ continue
+ try:
+ ci = swap_client.ci(c)
+ if hasattr(ci, "canExportToElectrum") and ci.canExportToElectrum():
+ seed_key = swap_client.getWalletKey(c, 1)
+ account_key = ci.getAccountKey(seed_key, zprv_prefix)
+ extended_keys[getCoinName(c)] = account_key
+ except Exception as e:
+ logger.debug(f"Could not generate extended key for {coin_name}: {e}")
+
finally:
if swap_client:
swap_client.finalise()
@@ -2113,6 +2144,18 @@ def initialise_wallets(
)
)
+ if extended_keys:
+ print("Extended private keys (for external wallet import):")
+ for coin_name, key in extended_keys.items():
+ print(f" {coin_name}: {key}")
+ print("")
+ print(
+ "NOTE: These keys can be imported into Electrum using 'Use a master key'."
+ )
+ print("WARNING: Write these down NOW. They will not be shown again.\n")
+
+ return extended_keys
+
def load_config(config_path):
if not os.path.exists(config_path):
@@ -2279,6 +2322,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 +2479,31 @@ 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:
+ if len(parts) >= 3:
+ server = f"{parts[0]}:{parts[1]}:{parts[2]}"
+ else:
+ server = f"{parts[0]}:{parts[1]}"
+ 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 +2862,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_clearnet_servers"] = electrum_servers[
+ coin_prefix
+ ]
+
for coin_name, coin_settings in chainclients.items():
coin_id = getCoinIdFromName(coin_name)
coin_params = chainparams[coin_id]
@@ -3001,7 +3098,7 @@ def main():
)
if particl_wallet_mnemonic != "none":
- initialise_wallets(
+ extended_keys = initialise_wallets(
None,
{
add_coin,
@@ -3013,6 +3110,18 @@ def main():
extra_opts=extra_opts,
)
+ if extended_keys:
+ print("\nExtended private key (for external wallet import):")
+ for coin_name, key in extended_keys.items():
+ print(f" {coin_name}: {key}")
+ print("")
+ print(
+ "NOTE: This key can be imported into Electrum using 'Use a master key'."
+ )
+ print(
+ "WARNING: Write this down NOW. It will not be shown again.\n"
+ )
+
save_config(config_path, settings)
finally:
if "particl_daemon" in extra_opts:
diff --git a/basicswap/bin/run.py b/basicswap/bin/run.py
index 3214f80..1268bfc 100755
--- a/basicswap/bin/run.py
+++ b/basicswap/bin/run.py
@@ -36,22 +36,25 @@ def signal_handler(sig, frame):
os.write(
sys.stdout.fileno(), f"Signal {sig} detected, ending program.\n".encode("utf-8")
)
- if swap_client is not None and not swap_client.chainstate_delay_event.is_set():
- try:
- from basicswap.ui.page_amm import stop_amm_process, get_amm_status
+ try:
+ if swap_client is not None and not swap_client.chainstate_delay_event.is_set():
+ try:
+ from basicswap.ui.page_amm import stop_amm_process, get_amm_status
- amm_status = get_amm_status()
- if amm_status == "running":
- logger.info("Signal handler stopping AMM process...")
- success, msg = stop_amm_process(swap_client)
- if success:
- logger.info(f"AMM signal shutdown: {msg}")
- else:
- logger.warning(f"AMM signal shutdown warning: {msg}")
- except Exception as e:
- logger.error(f"Error stopping AMM in signal handler: {e}")
+ amm_status = get_amm_status()
+ if amm_status == "running":
+ logger.info("Signal handler stopping AMM process...")
+ success, msg = stop_amm_process(swap_client)
+ if success:
+ logger.info(f"AMM signal shutdown: {msg}")
+ else:
+ logger.warning(f"AMM signal shutdown warning: {msg}")
+ except Exception as e:
+ logger.error(f"Error stopping AMM in signal handler: {e}")
- swap_client.stopRunning()
+ swap_client.stopRunning()
+ except NameError:
+ pass
def checkPARTZmqConfigBeforeStart(part_settings, swap_settings):
@@ -632,7 +635,7 @@ def runClient(
)
fail_code: int = swap_client.fail_code
- del swap_client
+ swap_client = None
if os.path.exists(pids_path):
with open(pids_path) as fd:
diff --git a/basicswap/db.py b/basicswap/db.py
index b1e53f6..9b49fae 100644
--- a/basicswap/db.py
+++ b/basicswap/db.py
@@ -13,8 +13,8 @@ from enum import IntEnum, auto
from typing import Optional
-CURRENT_DB_VERSION = 33
-CURRENT_DB_DATA_VERSION = 7
+CURRENT_DB_VERSION = 34
+CURRENT_DB_DATA_VERSION = 8
class Concepts(IntEnum):
@@ -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..cae8782 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,
@@ -129,6 +138,14 @@ def upgradeDatabaseData(self, data_version):
"state_id": int(state),
},
)
+ if data_version > 0 and data_version < 8:
+ cursor.execute(
+ "UPDATE bidstates SET can_timeout = :can_timeout WHERE state_id = :state_id",
+ {
+ "can_timeout": 1,
+ "state_id": int(BidStates.BID_REQUEST_ACCEPTED),
+ },
+ )
if data_version > 0 and data_version < 4:
for state in (
BidStates.BID_REQUEST_SENT,
@@ -260,7 +277,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 +295,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/http_server.py b/basicswap/http_server.py
index ba30795..a440f0a 100644
--- a/basicswap/http_server.py
+++ b/basicswap/http_server.py
@@ -6,8 +6,10 @@
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
import os
+import gzip
import json
import shlex
+import hashlib
import secrets
import traceback
import threading
@@ -19,6 +21,7 @@ from jinja2 import Environment, PackageLoader
from socket import error as SocketError
from urllib import parse
from datetime import datetime, timedelta, timezone
+from email.utils import formatdate, parsedate_to_datetime
from http.cookies import SimpleCookie
from . import __version__
@@ -802,7 +805,6 @@ class HttpHandler(BaseHTTPRequestHandler):
if page == "static":
try:
static_path = os.path.join(os.path.dirname(__file__), "static")
- content = None
mime_type = ""
filepath = ""
if len(url_split) > 3 and url_split[2] == "sequence_diagrams":
@@ -835,9 +837,71 @@ class HttpHandler(BaseHTTPRequestHandler):
if mime_type == "" or not filepath:
raise ValueError("Unknown file type or path")
+ file_stat = os.stat(filepath)
+ mtime = file_stat.st_mtime
+ file_size = file_stat.st_size
+
+ etag_hash = hashlib.md5(f"{file_size}-{mtime}".encode()).hexdigest()
+ etag = f'"{etag_hash}"'
+ last_modified = formatdate(mtime, usegmt=True)
+
+ if_none_match = self.headers.get("If-None-Match")
+ if if_none_match:
+ if if_none_match.strip() == "*" or etag in [
+ t.strip() for t in if_none_match.split(",")
+ ]:
+ self.send_response(304)
+ self.send_header("ETag", etag)
+ self.send_header("Cache-Control", "public")
+ self.end_headers()
+ return b""
+
+ if_modified_since = self.headers.get("If-Modified-Since")
+ if if_modified_since and not if_none_match:
+ try:
+ ims_time = parsedate_to_datetime(if_modified_since)
+ file_time = datetime.fromtimestamp(int(mtime), tz=timezone.utc)
+ if file_time <= ims_time:
+ self.send_response(304)
+ self.send_header("Last-Modified", last_modified)
+ self.send_header("Cache-Control", "public")
+ self.end_headers()
+ return b""
+ except (TypeError, ValueError):
+ pass
+
+ is_lib = len(url_split) > 4 and url_split[3] == "libs"
+ if is_lib:
+ cache_control = "public, max-age=31536000, immutable"
+ elif url_split[2] in ("css", "js"):
+ cache_control = "public, max-age=3600, must-revalidate"
+ elif url_split[2] in ("images", "sequence_diagrams"):
+ cache_control = "public, max-age=86400"
+ else:
+ cache_control = "public, max-age=3600"
+
with open(filepath, "rb") as fp:
content = fp.read()
- self.putHeaders(status_code, mime_type)
+
+ extra_headers = [
+ ("Cache-Control", cache_control),
+ ("Last-Modified", last_modified),
+ ("ETag", etag),
+ ]
+
+ is_compressible = mime_type in (
+ "text/css; charset=utf-8",
+ "application/javascript",
+ "image/svg+xml",
+ )
+ accept_encoding = self.headers.get("Accept-Encoding", "")
+ if is_compressible and "gzip" in accept_encoding:
+ content = gzip.compress(content)
+ extra_headers.append(("Content-Encoding", "gzip"))
+ extra_headers.append(("Vary", "Accept-Encoding"))
+
+ extra_headers.append(("Content-Length", str(len(content))))
+ self.putHeaders(status_code, mime_type, extra_headers=extra_headers)
return content
except FileNotFoundError:
diff --git a/basicswap/interface/btc.py b/basicswap/interface/btc.py
index 8fba86c..db88b87 100644
--- a/basicswap/interface/btc.py
+++ b/basicswap/interface/btc.py
@@ -14,6 +14,8 @@ import mmap
import os
import shutil
import sqlite3
+import threading
+import time
import traceback
from io import BytesIO
@@ -183,6 +185,8 @@ def extractScriptLockRefundScriptValues(script_bytes: bytes):
class BTCInterface(Secp256k1Interface):
+ _scantxoutset_lock = threading.Lock()
+ _MAX_SCANTXOUTSET_RETRIES = 3
@staticmethod
def coin_type():
@@ -231,6 +235,10 @@ class BTCInterface(Secp256k1Interface):
def xmr_swap_b_lock_spend_tx_vsize() -> int:
return 110
+ @staticmethod
+ def getdustlimit() -> int:
+ return 5460
+
@staticmethod
def txoType():
return CTxOut
@@ -304,6 +312,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 +371,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:
@@ -330,7 +384,7 @@ class BTCInterface(Secp256k1Interface):
f"Wallet: {self._rpc_wallet} not active, attempting to load."
)
try:
- self.rpc_wallet(
+ self.rpc(
"loadwallet",
[
self._rpc_wallet,
@@ -338,7 +392,31 @@ class BTCInterface(Secp256k1Interface):
)
wallets = self.rpc("listwallets")
except Exception as e:
- self._log.debug(f'Error loading wallet "self._rpc_wallet": {e}.')
+ self._log.debug(f'Error loading wallet "{self._rpc_wallet}": {e}.')
+ if "does not exist" in str(e) or "Path does not exist" in str(e):
+ self._log.info(
+ f'Creating wallet "{self._rpc_wallet}" for {self.coin_name()}.'
+ )
+ try:
+ self.rpc(
+ "createwallet",
+ [
+ self._rpc_wallet,
+ False,
+ True,
+ "",
+ False,
+ self._use_descriptors,
+ ],
+ )
+ wallets = self.rpc("listwallets")
+ if self.getWalletSeedID() == "Not found":
+ self._log.info(
+ f"Initializing HD seed for {self.coin_name()}."
+ )
+ self._sc.initialiseWallet(self.coin_type())
+ except Exception as create_e:
+ self._log.error(f"Error creating wallet: {create_e}")
# Wallet name is "" for some LTC and PART installs on older cores
if self._rpc_wallet not in wallets and len(wallets) > 0:
@@ -369,30 +447,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 +550,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()
@@ -462,6 +608,7 @@ class BTCInterface(Secp256k1Interface):
self.rpc_wallet("sethdseed", [True, key_wif])
except Exception as e:
self._log.debug(f"sethdseed failed: {e}")
+
"""
# TODO: Find derived key counts
if "Already have this key" in str(e):
@@ -469,7 +616,11 @@ class BTCInterface(Secp256k1Interface):
self.setActiveKeyChain(key_id)
else:
"""
- raise (e)
+ if "Already have this key" not in str(e):
+ raise (e)
+ self._log.info(
+ f"{self.coin_name()} wallet already has the correct HD seed."
+ )
def canExportToElectrum(self) -> bool:
# keychains must be unhardened to export into electrum
@@ -516,13 +667,131 @@ 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
+ )
+
+ try:
+ self._backend.estimateFee(self._conf_target)
+ except Exception:
+ pass
+
+ try:
+ coin_type = self.coin_type()
+ if coin_type in (Coins.BTC, Coins.LTC):
+ result = self._sc._computeElectrumLegacyFundsInfo(coin_type)
+ self._sc._cached_electrum_legacy_funds[int(coin_type)] = result
+ except Exception:
+ pass
+ 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 +804,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 +838,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 +875,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 +905,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 +960,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 +1053,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 +1785,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 +1799,181 @@ 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()
+
+ dummy_witness_stack = []
+ 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)
+ )
+ dummy_witness_stack.append(self.getP2WPKHDummyWitness())
+
+ for out in parsed_tx.vout:
+ funded_tx.vout.append(out)
+
+ witness_bytes_len_est: int = self.getWitnessStackSerialisedLength(
+ dummy_witness_stack
+ )
+
+ feerate_satkb = (
+ feerate if isinstance(feerate, int) else int(feerate * 100000000)
+ )
+ min_relay_fee = 250
+
+ rough_vsize = 10 + (len(funded_tx.vout) + 1) * 34 + len(selected_utxos) * 68
+ rough_fee = max(round(feerate_satkb * rough_vsize / 1000), min_relay_fee)
+ rough_change = total_input - total_output - rough_fee
+ dust_limit = self.getdustlimit()
+
+ if rough_change > dust_limit:
+ 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()(rough_change, change_script))
+
+ final_vsize = self.getTxVSize(
+ funded_tx, add_witness_bytes=witness_bytes_len_est
+ )
+ final_fee = max(round(feerate_satkb * final_vsize / 1000), min_relay_fee)
+ change = total_input - total_output - final_fee
+
+ if change > dust_limit:
+ funded_tx.vout[-1].nValue = change
+ else:
+ funded_tx.vout.pop()
+ final_vsize = self.getTxVSize(
+ funded_tx, add_witness_bytes=witness_bytes_len_est
+ )
+ final_fee = max(
+ round(feerate_satkb * final_vsize / 1000), min_relay_fee
+ )
+ change = 0
+ else:
+ final_vsize = self.getTxVSize(
+ funded_tx, add_witness_bytes=witness_bytes_len_est
+ )
+ final_fee = max(round(feerate_satkb * final_vsize / 1000), min_relay_fee)
+ change = 0
+
+ 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}"
+ )
+ self._log.info_s(
+ "_fundTxElectrum tx amount, vsize, feerate(sat/kB): %ld, %ld, %ld",
+ total_output,
+ final_vsize,
+ feerate_satkb,
+ )
+
+ return tx_serialized
+
def getNonSegwitOutputs(self):
unspents = self.rpc_wallet("listunspent", [0, 99999999])
nonsegwit_unspents = []
@@ -1474,7 +2003,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 +2015,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 +2043,9 @@ class BTCInterface(Secp256k1Interface):
return inputs
def unlockInputs(self, tx_bytes):
+ if self.useBackend():
+ return
+
tx = self.loadTx(tx_bytes)
inputs = []
@@ -1508,10 +2054,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 +2224,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()
@@ -1580,23 +2438,150 @@ class BTCInterface(Secp256k1Interface):
def getPkDest(self, K: bytes) -> bytearray:
return self.getScriptForPubkeyHash(self.getPubkeyHash(K))
+ def _rpc_scantxoutset(self, descriptors: list):
+ with BTCInterface._scantxoutset_lock:
+ for attempt in range(self._MAX_SCANTXOUTSET_RETRIES):
+ try:
+ return self.rpc("scantxoutset", ["start", descriptors])
+ except ValueError as e:
+ if "Scan already in progress" in str(e):
+ self._log.warning(
+ "scantxoutset: scan already in progress (attempt %d/%d), aborting",
+ attempt + 1,
+ self._MAX_SCANTXOUTSET_RETRIES,
+ )
+ try:
+ self.rpc("scantxoutset", ["abort"])
+ except Exception as abort_err:
+ self._log.debug(
+ "scantxoutset abort returned: %s", abort_err
+ )
+ time.sleep(0.5)
+ else:
+ raise
+ raise ValueError(
+ "scantxoutset failed after {} retries – scan could not be started".format(
+ self._MAX_SCANTXOUTSET_RETRIES
+ )
+ )
+
def scanTxOutset(self, dest):
- return self.rpc("scantxoutset", ["start", ["raw({})".format(dest.hex())]])
+ if self._connection_type == "electrum":
+ return self._scanTxOutsetElectrum(dest)
+ return self._rpc_scantxoutset(["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()
@@ -1634,15 +2619,19 @@ class BTCInterface(Secp256k1Interface):
def encodeSharedAddress(self, Kbv, Kbs):
return self.pubkey_to_segwit_address(Kbs)
- def publishBLockTx(
- self, kbv, Kbs, output_amount, feerate, unlock_time: int = 0
- ) -> bytes:
+ def publishBLockTx(self, kbv, Kbs, output_amount, feerate, unlock_time: int = 0):
b_lock_tx = self.createBLockTx(Kbs, output_amount)
b_lock_tx = self.fundTx(b_lock_tx, feerate)
+
+ script_pk = self.getPkDest(Kbs)
+ funded_tx = self.loadTx(b_lock_tx)
+ lock_vout = findOutput(funded_tx, script_pk)
+
b_lock_tx = self.signTxWithWallet(b_lock_tx)
- return bytes.fromhex(self.publishTx(b_lock_tx))
+ txid = bytes.fromhex(self.publishTx(b_lock_tx))
+ return txid, lock_vout
def getTxVSize(self, tx, add_bytes: int = 0, add_witness_bytes: int = 0) -> int:
wsf = self.witnessScaleFactor()
@@ -1666,7 +2655,9 @@ class BTCInterface(Secp256k1Interface):
if self.using_segwit()
else self.pubkey_to_address(Kbs)
)
- return self.getLockTxHeight(None, dest_address, cb_swap_value, restore_height)
+ return self.getLockTxHeight(
+ None, dest_address, cb_swap_value, restore_height, find_index=True
+ )
"""
raw_dest = self.getPkDest(Kbs)
@@ -1708,12 +2699,34 @@ class BTCInterface(Secp256k1Interface):
self._log.id(chain_b_lock_txid), lock_tx_vout
)
)
- locked_n = lock_tx_vout
-
Kbs = self.getPubkey(kbs)
script_pk = self.getPkDest(Kbs)
- if locked_n is None:
+ locked_n = None
+ actual_value = None
+ 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 not None:
+ actual_value = lock_tx.vout[locked_n].nValue
+ else:
+ 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 backend"
+ )
+ locked_n = lock_tx_vout
+ else:
wtx = self.rpc_wallet_watch(
"gettransaction",
[
@@ -1722,8 +2735,30 @@ class BTCInterface(Secp256k1Interface):
)
lock_tx = self.loadTx(bytes.fromhex(wtx["hex"]))
locked_n = findOutput(lock_tx, script_pk)
+ if locked_n is not None:
+ actual_value = lock_tx.vout[locked_n].nValue
+
+ if (
+ locked_n is not None
+ and lock_tx_vout is not None
+ and locked_n != lock_tx_vout
+ ):
+ self._log.warning(
+ f"spendBLockTx: Stored vout {lock_tx_vout} differs from actual vout {locked_n} "
+ f"for tx {chain_b_lock_txid.hex()}"
+ )
+
ensure(locked_n is not None, "Output not found in tx")
+ spend_value = cb_swap_value
+ if spend_actual_balance and actual_value is not None:
+ if actual_value != cb_swap_value:
+ self._log.warning(
+ f"spendBLockTx: Spending actual balance {actual_value}, "
+ f"not expected swap value {cb_swap_value}."
+ )
+ spend_value = actual_value
+
pkh_to = self.decodeAddress(address_to)
tx = CTransaction()
@@ -1739,19 +2774,27 @@ class BTCInterface(Secp256k1Interface):
scriptSig=self.getScriptScriptSig(script_lock),
)
)
- tx.vout.append(
- self.txoType()(cb_swap_value, self.getScriptForPubkeyHash(pkh_to))
- )
+ tx.vout.append(self.txoType()(spend_value, self.getScriptForPubkeyHash(pkh_to)))
pay_fee = self.getBLockSpendTxFee(tx, b_fee)
- tx.vout[0].nValue = cb_swap_value - pay_fee
+ tx.vout[0].nValue = spend_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=spend_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 +2827,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,11 +2899,217 @@ 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:
+ error_msg = str(e).lower()
+ if "no such mempool or blockchain transaction" not in error_msg:
+ 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())]]
- )
+ utxos = self._rpc_scantxoutset(["raw({})".format(dest_script.hex())])
if "height" in utxos: # chain_height not returned by v18 codebase
chain_height = utxos["height"]
else:
@@ -1883,10 +3137,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"))
@@ -1960,9 +3267,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):
@@ -1985,6 +3370,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)}]
)
@@ -1999,18 +3387,153 @@ 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
+ min_fee = 250
+ est_fee = max(est_vsize * fee_per_vbyte, min_fee)
+
+ 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:
@@ -2035,27 +3558,142 @@ 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")
+
+ self._log.debug("scantxoutset start")
+ ro = self._rpc_scantxoutset(["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():
@@ -2155,31 +3793,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
@@ -2187,10 +3857,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()
@@ -2277,7 +3951,7 @@ class BTCInterface(Secp256k1Interface):
self.rpc("loadwallet", [self._rpc_wallet])
- self.rpc_wallet("encryptwallet", [password])
+ self.rpc_wallet("encryptwallet", [password], timeout=120)
if check_seed is False or seed_id_before == "Not found" or walletpath is None:
return
@@ -2401,18 +4075,23 @@ class BTCInterface(Secp256k1Interface):
if self.isWalletEncrypted():
raise ValueError("Old password must be set")
return self.encryptWallet(new_password, check_seed=check_seed_if_encrypt)
- self.rpc_wallet("walletpassphrasechange", [old_password, new_password])
+ self.rpc_wallet(
+ "walletpassphrasechange", [old_password, new_password], timeout=120
+ )
def unlockWallet(self, password: str, check_seed: bool = True) -> None:
if password == "":
return
self._log.info(f"unlockWallet - {self.ticker()}")
- if self.coin_type() == Coins.BTC:
+ if self.useBackend():
+ return
+
+ if self.coin_type() in (Coins.BTC, Coins.LTC):
# Recreate wallet if none found
- # Required when encrypting an existing btc wallet, workaround is to delete the btc wallet and recreate
+ # Required when encrypting an existing btc/ltc wallet, or switching from electrum to rpc mode. Workaround is to delete the btc/ltc wallet and recreate.
wallets = self.rpc("listwallets")
- if len(wallets) < 1:
+ if self._rpc_wallet not in wallets:
self._log.info(
f'Creating wallet "{self._rpc_wallet}" for {self.coin_name()}.'
)
@@ -2429,14 +4108,54 @@ class BTCInterface(Secp256k1Interface):
],
)
- # Max timeout value, ~3 years
- self.rpc_wallet("walletpassphrase", [password, 100000000])
+ try:
+ seed_id = self.getWalletSeedID()
+ needs_seed_init = seed_id == "Not found"
+ except Exception as e:
+ self._log.debug(f"getWalletSeedID failed: {e}")
+ needs_seed_init = True
+ if needs_seed_init:
+ self._log.info(f"Initializing HD seed for {self.coin_name()}.")
+ self._sc.initialiseWallet(self.coin_type())
+ if password:
+ self._log.info(f"Encrypting {self.coin_name()} wallet.")
+ try:
+ self.rpc_wallet("encryptwallet", [password], timeout=120)
+ except Exception as e:
+ self._log.debug(f"encryptwallet returned: {e}")
+ for i in range(10):
+ time.sleep(1)
+ try:
+ self.rpc("listwallets")
+ break
+ except Exception:
+ self._log.debug(
+ f"Waiting for wallet after encryption... {i + 1}/10"
+ )
+ wallets = self.rpc("listwallets")
+ if self._rpc_wallet not in wallets:
+ self.rpc("loadwallet", [self._rpc_wallet])
+ self.setWalletSeedWarning(False)
+ check_seed = False
+
+ if self.isWalletEncrypted():
+ self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
if check_seed:
self._sc.checkWalletSeed(self.coin_type())
def lockWallet(self):
self._log.info(f"lockWallet - {self.ticker()}")
- self.rpc_wallet("walletlock")
+ if self.useBackend():
+ return
+ try:
+ self.rpc_wallet("walletlock")
+ except Exception as e:
+ if "unencrypted wallet" in str(e).lower():
+ self._log.debug(
+ f"lockWallet skipped - {self.ticker()} wallet is not encrypted"
+ )
+ return
+ raise
def get_p2sh_script_pubkey(self, script: bytearray) -> bytearray:
script_hash = hash160(script)
@@ -2447,6 +4166,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])
@@ -2459,6 +4181,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:
@@ -2466,8 +4254,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()
@@ -2491,8 +4278,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()
@@ -2544,7 +4330,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/dash.py b/basicswap/interface/dash.py
index 72517e1..1cfac85 100644
--- a/basicswap/interface/dash.py
+++ b/basicswap/interface/dash.py
@@ -132,7 +132,7 @@ class DASHInterface(BTCInterface):
self.unlockWallet(old_password, check_seed=False)
seed_id_before: str = self.getWalletSeedID()
- self.rpc_wallet("encryptwallet", [new_password])
+ self.rpc_wallet("encryptwallet", [new_password], timeout=120)
if check_seed is False or seed_id_before == "Not found":
return
@@ -156,4 +156,6 @@ class DASHInterface(BTCInterface):
if self.isWalletEncrypted():
raise ValueError("Old password must be set")
return self.encryptWallet(old_password, new_password, check_seed_if_encrypt)
- self.rpc_wallet("walletpassphrasechange", [old_password, new_password])
+ self.rpc_wallet(
+ "walletpassphrasechange", [old_password, new_password], timeout=120
+ )
diff --git a/basicswap/interface/dcr/dcr.py b/basicswap/interface/dcr/dcr.py
index 5872737..d20ac6c 100644
--- a/basicswap/interface/dcr/dcr.py
+++ b/basicswap/interface/dcr/dcr.py
@@ -188,6 +188,10 @@ class DCRInterface(Secp256k1Interface):
def coin_type():
return Coins.DCR
+ @staticmethod
+ def useBackend() -> bool:
+ return False
+
@staticmethod
def exp() -> int:
return 8
@@ -364,7 +368,9 @@ class DCRInterface(Secp256k1Interface):
# Read initial pwd from settings
settings = self._sc.getChainClientSettings(self.coin_type())
old_password = settings["wallet_pwd"]
- self.rpc_wallet("walletpassphrasechange", [old_password, new_password])
+ self.rpc_wallet(
+ "walletpassphrasechange", [old_password, new_password], timeout=120
+ )
# Lock wallet to match other coins
self.rpc_wallet("walletlock")
@@ -378,7 +384,7 @@ class DCRInterface(Secp256k1Interface):
self._log.info("unlockWallet - {}".format(self.ticker()))
# Max timeout value, ~3 years
- self.rpc_wallet("walletpassphrase", [password, 100000000])
+ self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
if check_seed:
self._sc.checkWalletSeed(self.coin_type())
@@ -632,6 +638,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
@@ -1055,6 +1070,9 @@ class DCRInterface(Secp256k1Interface):
def describeTx(self, tx_hex: str):
return self.rpc("decoderawtransaction", [tx_hex])
+ def decodeRawTransaction(self, tx_hex: str):
+ return self.rpc("decoderawtransaction", [tx_hex])
+
def fundTx(self, tx: bytes, feerate) -> bytes:
feerate_str = float(self.format_amount(feerate))
# TODO: unlock unspents if bid cancelled
@@ -1723,15 +1741,19 @@ class DCRInterface(Secp256k1Interface):
tx.vout.append(self.txoType()(output_amount, script_pk))
return tx.serialize()
- def publishBLockTx(
- self, kbv, Kbs, output_amount, feerate, unlock_time: int = 0
- ) -> bytes:
+ def publishBLockTx(self, kbv, Kbs, output_amount, feerate, unlock_time: int = 0):
b_lock_tx = self.createBLockTx(Kbs, output_amount)
b_lock_tx = self.fundTx(b_lock_tx, feerate)
+
+ script_pk = self.getPkDest(Kbs)
+ funded_tx = self.loadTx(b_lock_tx)
+ lock_vout = findOutput(funded_tx, script_pk)
+
b_lock_tx = self.signTxWithWallet(b_lock_tx)
- return bytes.fromhex(self.publishTx(b_lock_tx))
+ txid = bytes.fromhex(self.publishTx(b_lock_tx))
+ return txid, lock_vout
def getBLockSpendTxFee(self, tx, fee_rate: int) -> int:
witness_bytes = 115
@@ -1755,26 +1777,53 @@ class DCRInterface(Secp256k1Interface):
lock_tx_vout=None,
) -> bytes:
self._log.info("spendBLockTx %s:\n", chain_b_lock_txid.hex())
- locked_n = lock_tx_vout
Kbs = self.getPubkey(kbs)
script_pk = self.getPkDest(Kbs)
- if locked_n is None:
- self._log.debug(
- f"Unknown lock vout, searching tx: {chain_b_lock_txid.hex()}"
+ locked_n = None
+ actual_value = None
+ wtx = self.rpc_wallet(
+ "gettransaction",
+ [
+ chain_b_lock_txid.hex(),
+ ],
+ )
+ lock_tx = self.loadTx(bytes.fromhex(wtx["hex"]))
+ locked_n = findOutput(lock_tx, script_pk)
+ if locked_n is not None:
+ actual_value = lock_tx.vout[locked_n].value
+ else:
+ 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)}"
)
- # When refunding a lock tx, it should be in the wallet as a sent tx
- wtx = self.rpc_wallet(
- "gettransaction",
- [
- chain_b_lock_txid.hex(),
- ],
+ for i, out in enumerate(lock_tx.vout):
+ self._log.debug(
+ f" vout[{i}]: value={out.value}, scriptPubKey={out.scriptPubKey.hex()}"
+ )
+
+ if (
+ locked_n is not None
+ and lock_tx_vout is not None
+ and locked_n != lock_tx_vout
+ ):
+ self._log.warning(
+ f"spendBLockTx: Stored vout {lock_tx_vout} differs from actual vout {locked_n} "
+ f"for tx {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")
+
+ spend_value = cb_swap_value
+ if spend_actual_balance and actual_value is not None:
+ if actual_value != cb_swap_value:
+ self._log.warning(
+ f"spendBLockTx: Spending actual balance {actual_value}, "
+ f"not expected swap value {cb_swap_value}."
+ )
+ spend_value = actual_value
+
pkh_to = self.decodeAddress(address_to)
tx = CTransaction()
@@ -1783,10 +1832,10 @@ class DCRInterface(Secp256k1Interface):
chain_b_lock_txid_int = b2i(chain_b_lock_txid)
tx.vin.append(CTxIn(COutPoint(chain_b_lock_txid_int, locked_n, 0), sequence=0))
- tx.vout.append(self.txoType()(cb_swap_value, self.getPubkeyHashDest(pkh_to)))
+ tx.vout.append(self.txoType()(spend_value, self.getPubkeyHashDest(pkh_to)))
pay_fee = self.getBLockSpendTxFee(tx, b_fee)
- tx.vout[0].value = cb_swap_value - pay_fee
+ tx.vout[0].value = spend_value - pay_fee
b_lock_spend_tx = tx.serialize()
b_lock_spend_tx = self.signTxWithKey(b_lock_spend_tx, kbs)
diff --git a/basicswap/interface/electrumx.py b/basicswap/interface/electrumx.py
new file mode 100644
index 0000000..b8d27db
--- /dev/null
+++ b/basicswap/interface/electrumx.py
@@ -0,0 +1,1305 @@
+#!/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": "bitcoin.stackwallet.com", "port": 50002, "ssl": True},
+ {"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": "litecoin.stackwallet.com", "port": 20063, "ssl": True},
+ {"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)
+ queues = list(self._response_queues.values())
+ for q in queues:
+ 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:
+ self._connected = False
+ 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}")
+ self._connected = False
+ 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:
+ response = None
+ while response is None:
+ remaining = deadline - time.time()
+ if remaining <= 0:
+ raise TemporaryError("Batch request timed out")
+ if not self._connected:
+ raise TemporaryError("Connection closed during batch request")
+ poll_time = min(remaining, 2.0)
+ try:
+ response = self._response_queues[req_id].get(timeout=poll_time)
+ except queue.Empty:
+ continue
+ try:
+ if "error" in response and response["error"]:
+ error_msg = str(response["error"])
+ if "Connection closed" in error_msg:
+ raise TemporaryError("Connection closed during batch request")
+ results[req_id] = {"error": response["error"]}
+ else:
+ results[req_id] = {"result": response.get("result")}
+ 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
+ if len(parts) > 2:
+ ssl_str = parts[2].lower()
+ use_ssl = ssl_str in ("true", "1", "yes", "ssl")
+ else:
+ 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._stopping = False
+
+ 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._consecutive_timeouts = 0
+ self._max_consecutive_timeouts = 5
+ self._last_timeout_time = 0
+ self._timeout_decay_seconds = 90
+
+ self._keepalive_thread = None
+ self._keepalive_running = False
+ self._keepalive_interval = 15
+ self._last_activity = 0
+ self._last_reconnect_time = 0
+
+ self._min_request_interval = 0.02
+ self._last_request_time = 0
+
+ self._user_connection = None
+ self._user_lock = threading.Lock()
+ self._user_last_activity = 0
+ self._user_connection_logged = False
+
+ self._subscribed_height = 0
+ self._subscribed_height_time = 0
+ self._height_callback = None
+
+ self._initial_connection_logged = False
+
+ 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):
+ if self._stopping:
+ return
+ 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]
+ prev_host = self._current_server_host
+ prev_port = self._current_server_port
+ 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()
+ self._last_reconnect_time = time.time()
+ if self._log:
+ if not self._initial_connection_logged:
+ self._log.info(
+ f"Connected to Electrum server: {server['host']}:{server['port']} "
+ f"({self._server_version}, {connect_time:.0f}ms)"
+ )
+ self._initial_connection_logged = True
+ elif server["host"] != prev_host or server["port"] != prev_port:
+ self._log.info(
+ f"Switched to Electrum server: {server['host']}:{server['port']} "
+ f"({connect_time:.0f}ms)"
+ )
+ if self._stopping:
+ conn.disconnect()
+ self._connection = None
+ return
+ 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))
+ 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]:
+ 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=20
+ )
+ if result and isinstance(result, dict):
+ height = result.get("height", 0)
+ if height > 0:
+ self._on_header_update(height)
+ except Exception:
+ pass
+
+ def register_height_callback(self, callback):
+ self._height_callback = callback
+
+ def get_subscribed_height(self) -> int:
+ return self._subscribed_height
+
+ def recently_reconnected(self, grace_seconds: int = 30) -> bool:
+ if self._last_reconnect_time == 0:
+ return False
+ return (time.time() - self._last_reconnect_time) < grace_seconds
+
+ 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)
+
+ now = time.time()
+ if now - 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):
+ if self._stopping:
+ return
+ 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):
+ if self._stopping:
+ raise TemporaryError("Electrum server is shutting down")
+ 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._stopping:
+ raise TemporaryError("Electrum server is shutting down")
+ if self._connection is None or not self._connection.is_connected():
+ self.connect()
+ if self._connection is None:
+ raise TemporaryError("Failed to establish Electrum connection")
+ elif (time.time() - self._last_activity) > 60:
+ if not self._check_connection_health():
+ self._retry_on_failure()
+ if self._connection is None:
+ raise TemporaryError(
+ "Failed to re-establish Electrum connection"
+ )
+ 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):
+ if self._stopping:
+ raise TemporaryError("Electrum server is shutting down")
+ 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._stopping:
+ raise TemporaryError("Electrum server is shutting down")
+ if self._connection is None or not self._connection.is_connected():
+ self.connect()
+ if self._connection is None:
+ raise TemporaryError("Failed to establish Electrum connection")
+ elif (time.time() - self._last_activity) > 60:
+ if not self._check_connection_health():
+ self._retry_on_failure()
+ if self._connection is None:
+ raise TemporaryError(
+ "Failed to re-establish Electrum connection"
+ )
+ 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_user(self):
+ if self._stopping:
+ return False
+ 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()
+ conn.get_server_version()
+ self._user_connection = conn
+ self._user_last_activity = time.time()
+ if self._log:
+ if not self._user_connection_logged:
+ self._log.debug(
+ f"User connection established to {server['host']}"
+ )
+ self._user_connection_logged = True
+ else:
+ self._log.debug(
+ f"User connection reconnected to {server['host']}"
+ )
+ return True
+ except Exception as e:
+ if self._log:
+ self._log.debug(f"User connection failed to {server['host']}: {e}")
+ continue
+ return False
+
+ def _record_timeout(self):
+ if self._stopping:
+ return
+ now = time.time()
+ if (
+ now - self._last_timeout_time
+ ) > self._timeout_decay_seconds and self._last_timeout_time > 0:
+ self._consecutive_timeouts = 0
+ self._consecutive_timeouts += 1
+ self._last_timeout_time = now
+ if self._consecutive_timeouts >= self._max_consecutive_timeouts:
+ server = self._get_server(self._current_server_idx)
+ reason = f"{self._consecutive_timeouts} consecutive timeouts"
+ self._blacklist_server(server, reason)
+ self._consecutive_timeouts = 0
+ self._last_timeout_time = 0
+ try:
+ self._retry_on_failure()
+ except Exception:
+ pass
+
+ def call_background(self, method, params=None, timeout=20):
+ if self._stopping:
+ raise TemporaryError("Electrum server is shutting down")
+ conn = self._connection
+ if conn is None or not conn.is_connected():
+ if self._stopping:
+ raise TemporaryError("Electrum server is shutting down")
+ try:
+ self.connect()
+ conn = self._connection
+ except Exception:
+ raise TemporaryError("Electrum call failed: no connection")
+ if conn is None or not conn.is_connected():
+ raise TemporaryError("Electrum call failed: no connection")
+ try:
+ result = conn.call(method, params, timeout=timeout)
+ self._last_activity = time.time()
+ return result
+ except TemporaryError as e:
+ if self._stopping:
+ raise TemporaryError("Electrum server is shutting down")
+ if "timed out" in str(e).lower():
+ self._record_timeout()
+ raise
+
+ def call_batch_background(self, requests, timeout=30):
+ if self._stopping:
+ raise TemporaryError("Electrum server is shutting down")
+ conn = self._connection
+ if conn is None or not conn.is_connected():
+ if self._stopping:
+ raise TemporaryError("Electrum server is shutting down")
+ self._record_timeout()
+ conn = self._connection
+ if conn is None or not conn.is_connected():
+ try:
+ self.connect()
+ conn = self._connection
+ except Exception:
+ raise TemporaryError("Electrum batch call failed: no connection")
+ if conn is None or not conn.is_connected():
+ raise TemporaryError("Electrum batch call failed: no connection")
+ try:
+ result = conn.call_batch(requests)
+ self._last_activity = time.time()
+ return result
+ except TemporaryError as e:
+ if self._stopping:
+ raise TemporaryError("Electrum server is shutting down")
+ if "timed out" in str(e).lower():
+ self._record_timeout()
+ raise
+
+ def call_user(self, method, params=None, timeout=10):
+ if self._stopping:
+ raise TemporaryError("Electrum server is shutting down")
+ lock_acquired = self._user_lock.acquire(timeout=timeout + 2)
+ if not lock_acquired:
+ raise TemporaryError(f"User connection busy: {method}")
+
+ try:
+ if (
+ self._user_connection is None
+ or not self._user_connection.is_connected()
+ ):
+ if not self._connect_user():
+ raise TemporaryError("User connection unavailable")
+
+ try:
+ result = self._user_connection.call(method, params, timeout=timeout)
+ self._user_last_activity = time.time()
+ return result
+ except Exception as e:
+ if self._log:
+ self._log.debug(f"User call failed ({method}): {e}")
+ if self._user_connection:
+ try:
+ self._user_connection.disconnect()
+ except Exception:
+ pass
+ self._user_connection = None
+
+ if self._connect_user():
+ try:
+ result = self._user_connection.call(
+ method, params, timeout=timeout
+ )
+ self._user_last_activity = time.time()
+ return result
+ except Exception as e2:
+ raise TemporaryError(f"User call failed: {e2}")
+
+ raise TemporaryError(f"User call failed: {e}")
+ finally:
+ self._user_lock.release()
+
+ def call_batch_user(self, requests, timeout=15):
+ if self._stopping:
+ raise TemporaryError("Electrum server is shutting down")
+ lock_acquired = self._user_lock.acquire(timeout=timeout + 2)
+ if not lock_acquired:
+ raise TemporaryError("User connection busy")
+
+ try:
+ if (
+ self._user_connection is None
+ or not self._user_connection.is_connected()
+ ):
+ if not self._connect_user():
+ raise TemporaryError("User connection unavailable")
+
+ try:
+ result = self._user_connection.call_batch(requests)
+ self._user_last_activity = time.time()
+ return result
+ except Exception as e:
+ if self._log:
+ self._log.debug(f"User batch call failed: {e}")
+ if self._user_connection:
+ try:
+ self._user_connection.disconnect()
+ except Exception:
+ pass
+ self._user_connection = None
+
+ if self._connect_user():
+ try:
+ result = self._user_connection.call_batch(requests)
+ self._user_last_activity = time.time()
+ return result
+ except Exception as e2:
+ raise TemporaryError(f"User batch call failed: {e2}")
+
+ raise TemporaryError(f"User batch call failed: {e}")
+ finally:
+ self._user_lock.release()
+
+ def disconnect(self):
+ self._stop_keepalive()
+ lock_acquired = self._lock.acquire(timeout=5)
+ if lock_acquired:
+ try:
+ if self._connection:
+ self._connection.disconnect()
+ self._connection = None
+ finally:
+ self._lock.release()
+ else:
+ conn = self._connection
+ if conn:
+ try:
+ conn.disconnect()
+ except Exception:
+ pass
+ with self._user_lock:
+ if self._user_connection:
+ try:
+ self._user_connection.disconnect()
+ except Exception:
+ pass
+ self._user_connection = None
+ self._user_connection_logged = False
+
+ def shutdown(self):
+ self._stopping = True
+ self.disconnect()
+
+ 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/firo.py b/basicswap/interface/firo.py
index a02bb07..f31cbe8 100644
--- a/basicswap/interface/firo.py
+++ b/basicswap/interface/firo.py
@@ -64,7 +64,7 @@ class FIROInterface(BTCInterface):
# Firo shuts down after encryptwallet
seed_id_before: str = self.getWalletSeedID() if check_seed else "Not found"
- self.rpc_wallet("encryptwallet", [password])
+ self.rpc_wallet("encryptwallet", [password], timeout=120)
if check_seed is False or seed_id_before == "Not found":
return
diff --git a/basicswap/interface/ltc.py b/basicswap/interface/ltc.py
index bd0fa96..897f69b 100644
--- a/basicswap/interface/ltc.py
+++ b/basicswap/interface/ltc.py
@@ -26,13 +26,96 @@ class LTCInterface(BTCInterface):
wallet=self._rpc_wallet_mweb,
)
+ 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:
+ self._log.debug(
+ f"Wallet: {self._rpc_wallet} not active, attempting to load."
+ )
+ try:
+ self.rpc(
+ "loadwallet",
+ [
+ self._rpc_wallet,
+ ],
+ )
+ wallets = self.rpc("listwallets")
+ except Exception as e:
+ self._log.debug(f'Error loading wallet "{self._rpc_wallet}": {e}.')
+ if "does not exist" in str(e) or "Path does not exist" in str(e):
+ self._log.info(
+ f'Creating wallet "{self._rpc_wallet}" for {self.coin_name()}.'
+ )
+ try:
+ self.rpc(
+ "createwallet",
+ [
+ self._rpc_wallet,
+ False,
+ True,
+ "",
+ False,
+ self._use_descriptors,
+ ],
+ )
+ wallets = self.rpc("listwallets")
+ if self.getWalletSeedID() == "Not found":
+ self._log.info(
+ f"Initializing HD seed for {self.coin_name()}."
+ )
+ self._sc.initialiseWallet(self.coin_type())
+ except Exception as create_e:
+ self._log.error(f"Error creating wallet: {create_e}")
+
+ if self._rpc_wallet not in wallets and len(wallets) > 0:
+ self._log.warning(f"Changing {self.ticker()} wallet name.")
+ for wallet_name in wallets:
+ if wallet_name in ("mweb",):
+ continue
+
+ change_watchonly_wallet: bool = (
+ self._rpc_wallet_watch == self._rpc_wallet
+ )
+
+ self._rpc_wallet = wallet_name
+ self._log.info(
+ f"Switched {self.ticker()} wallet name to {self._rpc_wallet}."
+ )
+ self.rpc_wallet = make_rpc_func(
+ self._rpcport,
+ self._rpcauth,
+ host=self._rpc_host,
+ wallet=self._rpc_wallet,
+ )
+ if change_watchonly_wallet:
+ self.rpc_wallet_watch = self.rpc_wallet
+ break
+
+ return len(wallets)
+
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 +136,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:
@@ -86,6 +182,69 @@ class LTCInterface(BTCInterface):
) + self.make_int(u["amount"], r=1)
return unspent_addr
+ def unlockWallet(self, password: str, check_seed: bool = True) -> None:
+ if password == "":
+ return
+ self._log.info("unlockWallet - {}".format(self.ticker()))
+
+ if self.useBackend():
+ return
+
+ wallets = self.rpc("listwallets")
+ if self._rpc_wallet not in wallets:
+ self._log.info(
+ f'Creating wallet "{self._rpc_wallet}" for {self.coin_name()}.'
+ )
+ self.rpc(
+ "createwallet",
+ [
+ self._rpc_wallet,
+ False,
+ True,
+ password,
+ False,
+ self._use_descriptors,
+ ],
+ )
+
+ try:
+ seed_id = self.getWalletSeedID()
+ needs_seed_init = seed_id == "Not found"
+ except Exception as e:
+ self._log.debug(f"getWalletSeedID failed: {e}")
+ needs_seed_init = True
+ if needs_seed_init:
+ self._log.info(f"Initializing HD seed for {self.coin_name()}.")
+ self._sc.initialiseWallet(self.coin_type())
+ if password:
+ self._log.info(f"Encrypting {self.coin_name()} wallet.")
+ try:
+ self.rpc_wallet("encryptwallet", [password], timeout=120)
+ except Exception as e:
+ self._log.debug(f"encryptwallet returned: {e}")
+ import time
+
+ for i in range(10):
+ time.sleep(1)
+ try:
+ self.rpc("listwallets")
+ break
+ except Exception:
+ self._log.debug(
+ f"Waiting for wallet after encryption... {i + 1}/10"
+ )
+ wallets = self.rpc("listwallets")
+ if self._rpc_wallet not in wallets:
+ self.rpc("loadwallet", [self._rpc_wallet])
+ self.setWalletSeedWarning(False)
+ check_seed = False
+
+ if self.isWalletEncrypted():
+ self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
+
+ if check_seed:
+ self._sc.checkWalletSeed(self.coin_type())
+
class LTCInterfaceMWEB(LTCInterface):
@@ -129,13 +288,49 @@ class LTCInterfaceMWEB(LTCInterface):
self._log.info("init_wallet - {}".format(self.ticker()))
- self._log.info(f"Creating wallet {self._rpc_wallet} for {self.coin_name()}.")
- # wallet_name, disable_private_keys, blank, passphrase, avoid_reuse, descriptors, load_on_startup
- self.rpc("createwallet", ["mweb", False, True, password, False, False, True])
+ wallets = self.rpc("listwallets")
+ if self._rpc_wallet not in wallets:
+ try:
+ self.rpc("loadwallet", [self._rpc_wallet])
+ self._log.debug(f'Loaded existing wallet "{self._rpc_wallet}".')
+ except Exception as e:
+ if "does not exist" in str(e) or "Path does not exist" in str(e):
+ self._log.info(
+ f'Creating wallet "{self._rpc_wallet}" for {self.coin_name()}.'
+ )
+ self.rpc(
+ "createwallet",
+ [
+ self._rpc_wallet,
+ False,
+ True,
+ password,
+ False,
+ self._use_descriptors,
+ ],
+ )
+ else:
+ raise
+
+ wallets = self.rpc("listwallets")
+ if "mweb" not in wallets:
+ try:
+ self.rpc("loadwallet", ["mweb"])
+ self._log.debug("Loaded existing MWEB wallet.")
+ except Exception as e:
+ if "does not exist" in str(e) or "Path does not exist" in str(e):
+ self._log.info(f"Creating MWEB wallet for {self.coin_name()}.")
+ # wallet_name, disable_private_keys, blank, passphrase, avoid_reuse, descriptors, load_on_startup
+ self.rpc(
+ "createwallet",
+ ["mweb", False, True, password, False, False, True],
+ )
+ else:
+ raise
if password is not None:
# Max timeout value, ~3 years
- self.rpc_wallet("walletpassphrase", [password, 100000000])
+ self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
if self.getWalletSeedID() == "Not found":
self._sc.initialiseWallet(self.interface_type())
@@ -144,7 +339,7 @@ class LTCInterfaceMWEB(LTCInterface):
self.rpc("unloadwallet", ["mweb"])
self.rpc("loadwallet", ["mweb"])
if password is not None:
- self.rpc_wallet("walletpassphrase", [password, 100000000])
+ self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
self.rpc_wallet("keypoolrefill")
def unlockWallet(self, password: str, check_seed: bool = True) -> None:
@@ -152,10 +347,22 @@ 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:
- # Max timeout value, ~3 years
- self.rpc_wallet("walletpassphrase", [password, 100000000])
+ self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
+ try:
+ seed_id = self.getWalletSeedID()
+ needs_seed_init = seed_id == "Not found"
+ except Exception as e:
+ self._log.debug(f"getWalletSeedID failed: {e}")
+ needs_seed_init = True
+ if needs_seed_init:
+ self._log.info(f"Initializing HD seed for {self.coin_name()}.")
+ self._sc.initialiseWallet(self.interface_type())
+
if check_seed:
- self._sc.checkWalletSeed(self.coin_type())
+ self._sc.checkWalletSeed(self.interface_type())
diff --git a/basicswap/interface/pivx.py b/basicswap/interface/pivx.py
index 4c791e0..d49ebde 100644
--- a/basicswap/interface/pivx.py
+++ b/basicswap/interface/pivx.py
@@ -40,7 +40,7 @@ class PIVXInterface(BTCInterface):
seed_id_before: str = self.getWalletSeedID()
- self.rpc_wallet("encryptwallet", [password])
+ self.rpc_wallet("encryptwallet", [password], timeout=120)
if check_seed is False or seed_id_before == "Not found":
return
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 21c80cf..e54c4c4 100644
--- a/basicswap/js_server.py
+++ b/basicswap/js_server.py
@@ -137,7 +137,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:
@@ -170,8 +170,29 @@ 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
+ if (
+ v["connection_type"] == "electrum"
+ and hasattr(ci, "_backend")
+ and ci._backend
+ and hasattr(ci._backend, "getSyncStatus")
+ ):
+ sync_status = ci._backend.getSyncStatus()
+ coin_entry["electrum_synced"] = sync_status.get("synced", False)
+ coin_entry["electrum_height"] = sync_status.get("height", 0)
+
coins_with_balances.append(coin_entry)
if k == Coins.PART:
@@ -295,6 +316,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")
@@ -308,6 +332,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:
@@ -601,8 +650,13 @@ def js_bids(self, url_split, post_string: str, is_json: bool) -> bytes:
)
if have_data_entry(post_data, "debugind"):
+ main_debug_ind: bool = toBool(
+ get_data_entry_or(post_data, "maindebugind", True)
+ )
swap_client.setBidDebugInd(
- bid_id, int(get_data_entry(post_data, "debugind"))
+ bid_id,
+ int(get_data_entry(post_data, "debugind")),
+ add_to_bid=main_debug_ind,
)
rv = {"bid_id": bid_id.hex()}
@@ -620,8 +674,13 @@ def js_bids(self, url_split, post_string: str, is_json: bool) -> bytes:
elif have_data_entry(post_data, "abandon"):
swap_client.abandonBid(bid_id)
elif have_data_entry(post_data, "debugind"):
+ main_debug_ind: bool = toBool(
+ get_data_entry_or(post_data, "maindebugind", True)
+ )
swap_client.setBidDebugInd(
- bid_id, int(get_data_entry(post_data, "debugind"))
+ bid_id,
+ int(get_data_entry(post_data, "debugind")),
+ add_to_bid=main_debug_ind,
)
if have_data_entry(post_data, "show_extra"):
@@ -630,7 +689,9 @@ def js_bids(self, url_split, post_string: str, is_json: bool) -> bytes:
with_events = True
bid, xmr_swap, offer, xmr_offer, events = swap_client.getXmrBidAndOffer(bid_id)
- assert bid, "Unknown bid ID"
+ if bid is None:
+ swap_client.log.debug(f"js_bids: Unknown bid id {bid_id.hex()}")
+ return bytes(json.dumps({"error": "Unknown bid id"}), "UTF-8")
if post_string != "":
if have_data_entry(post_data, "chainbkeysplit"):
@@ -1210,10 +1271,12 @@ def js_getcoinseed(self, url_split, post_string, is_json) -> bytes:
"current_seed_id": wallet_seed_id,
}
)
- if hasattr(ci, "canExportToElectrum") and ci.canExportToElectrum():
- rv.update(
- {"account_key": ci.getAccountKey(seed_key, extkey_prefix)}
- ) # Master key can be imported into electrum (Must set prefix for P2WPKH)
+
+ if hasattr(ci, "getAccountKey"):
+ try:
+ rv.update({"account_key": ci.getAccountKey(seed_key, extkey_prefix)})
+ except Exception as e:
+ rv.update({"account_key_error": str(e)})
return bytes(
json.dumps(rv),
@@ -1528,6 +1591,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)
@@ -1571,10 +1699,221 @@ def js_messageroutes(self, url_split, post_string, is_json) -> bytes:
return bytes(json.dumps(message_routes), "UTF-8")
+def js_modeswitchinfo(self, url_split, post_string, is_json) -> bytes:
+ swap_client = self.server.swap_client
+ swap_client.checkSystemStatus()
+ post_data = getFormData(post_string, is_json)
+
+ coin_str = get_data_entry(post_data, "coin")
+ direction = get_data_entry_or(post_data, "direction", "lite")
+
+ try:
+ coin_type = getCoinIdFromName(coin_str)
+ except Exception:
+ coin_type = getCoinIdFromTicker(coin_str.upper())
+
+ ci = swap_client.ci(coin_type)
+ ticker = ci.ticker()
+
+ try:
+ wallet_info = ci.getWalletInfo()
+ balance = wallet_info.get("balance", 0)
+ balance_sats = ci.make_int(balance)
+ except Exception as e:
+ return bytes(json.dumps({"error": f"Failed to get balance: {e}"}), "UTF-8")
+
+ try:
+ fee_rate, rate_src = ci.get_fee_rate(ci._conf_target)
+ est_vsize = 180
+ if isinstance(fee_rate, int):
+ fee_per_vbyte = max(1, fee_rate // 1000)
+ else:
+ fee_per_vbyte = max(1, int(fee_rate * 100000))
+ estimated_fee_sats = est_vsize * fee_per_vbyte
+ except Exception:
+ estimated_fee_sats = 180
+ rate_src = "default"
+
+ min_viable = estimated_fee_sats * 2
+ can_transfer = balance_sats > min_viable
+
+ rv = {
+ "coin": ticker,
+ "direction": direction,
+ "balance": balance,
+ "balance_sats": balance_sats,
+ "estimated_fee_sats": estimated_fee_sats,
+ "estimated_fee": ci.format_amount(estimated_fee_sats),
+ "fee_rate_src": rate_src,
+ "can_transfer": can_transfer,
+ "min_viable_sats": min_viable,
+ }
+
+ if direction == "lite":
+ non_bip84_balance_sats = 0
+ has_non_bip84_funds = False
+ try:
+ if hasattr(ci, "rpc_wallet"):
+ unspent = ci.rpc_wallet("listunspent")
+
+ wm = swap_client.getWalletManager()
+
+ bip84_addresses = set()
+ if wm:
+ try:
+ all_addrs = wm.getAllAddresses(
+ coin_type, include_watch_only=False
+ )
+ bip84_addresses = set(all_addrs)
+ except Exception:
+ pass
+
+ for u in unspent:
+ addr = u.get("address")
+ if not addr:
+ continue
+ amount_sats = ci.make_int(u.get("amount", 0))
+ if amount_sats <= 0:
+ continue
+
+ if addr not in bip84_addresses:
+ non_bip84_balance_sats += amount_sats
+ has_non_bip84_funds = True
+ except Exception as e:
+ swap_client.log.debug(f"Error checking non-BIP84 addresses: {e}")
+
+ if has_non_bip84_funds and non_bip84_balance_sats > min_viable:
+ rv["show_transfer_option"] = True
+ rv["require_transfer"] = True
+ rv["legacy_balance_sats"] = non_bip84_balance_sats
+ rv["legacy_balance"] = ci.format_amount(non_bip84_balance_sats)
+ rv["message"] = (
+ "Funds on non-derivable addresses must be transferred for external wallet compatibility"
+ )
+ else:
+ rv["show_transfer_option"] = False
+ rv["require_transfer"] = False
+ if has_non_bip84_funds:
+ rv["legacy_balance_sats"] = non_bip84_balance_sats
+ rv["legacy_balance"] = ci.format_amount(non_bip84_balance_sats)
+ rv["message"] = "Non-derivable balance too low to transfer"
+ else:
+ rv["legacy_balance_sats"] = 0
+ rv["legacy_balance"] = "0"
+ rv["message"] = "All funds on BIP84 addresses"
+ else:
+ rv["show_transfer_option"] = can_transfer
+ if balance_sats == 0:
+ rv["message"] = "No funds to transfer"
+ elif not can_transfer:
+ rv["message"] = "Balance too low to transfer (fee would exceed funds)"
+ else:
+ rv["message"] = ""
+
+ return bytes(json.dumps(rv), "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,
@@ -1604,6 +1943,8 @@ endpoints = {
"coinvolume": js_coinvolume,
"coinhistory": js_coinhistory,
"messageroutes": js_messageroutes,
+ "electrumdiscover": js_electrum_discover,
+ "modeswitchinfo": js_modeswitchinfo,
}
diff --git a/basicswap/rpc.py b/basicswap/rpc.py
index 9a400c4..94c9030 100644
--- a/basicswap/rpc.py
+++ b/basicswap/rpc.py
@@ -152,15 +152,17 @@ class Jsonrpc:
pass
-def callrpc(rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1"):
+def callrpc(
+ rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1", timeout=None
+):
if _use_rpc_pooling:
- return callrpc_pooled(rpc_port, auth, method, params, wallet, host)
+ return callrpc_pooled(rpc_port, auth, method, params, wallet, host, timeout)
try:
url = "http://{}@{}:{}/".format(auth, host, rpc_port)
if wallet is not None:
url += "wallet/" + urllib.parse.quote(wallet)
- x = Jsonrpc(url)
+ x = Jsonrpc(url, timeout=timeout if timeout else 10)
v = x.json_request(method, params)
x.close()
@@ -174,7 +176,9 @@ def callrpc(rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1"):
return r["result"]
-def callrpc_pooled(rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1"):
+def callrpc_pooled(
+ rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1", timeout=None
+):
from .rpc_pool import get_rpc_pool
import http.client
import socket
@@ -183,6 +187,20 @@ def callrpc_pooled(rpc_port, auth, method, params=[], wallet=None, host="127.0.0
if wallet is not None:
url += "wallet/" + urllib.parse.quote(wallet)
+ if timeout:
+ try:
+ conn = Jsonrpc(url, timeout=timeout)
+ v = conn.json_request(method, params)
+ r = json.loads(v.decode("utf-8"))
+ conn.close()
+ if "error" in r and r["error"] is not None:
+ raise ValueError("RPC error " + str(r["error"]))
+ return r["result"]
+ except ValueError:
+ raise
+ except Exception as ex:
+ raise ValueError(f"RPC server error: {ex}, method: {method}")
+
max_connections = _rpc_pool_settings.get("max_connections_per_daemon", 5)
pool = get_rpc_pool(url, max_connections)
@@ -247,7 +265,7 @@ def make_rpc_func(port, auth, wallet=None, host="127.0.0.1"):
wallet = wallet
host = host
- def rpc_func(method, params=None, wallet_override=None):
+ def rpc_func(method, params=None, wallet_override=None, timeout=None):
return callrpc(
port,
auth,
@@ -255,6 +273,7 @@ def make_rpc_func(port, auth, wallet=None, host="127.0.0.1"):
params,
wallet if wallet_override is None else wallet_override,
host,
+ timeout=timeout,
)
return rpc_func
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..30c5d10 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'
@@ -609,7 +610,7 @@ function ensureToastContainer() {
clickAction = `onclick="window.location.href='/bid/${options.bidId}'"`;
cursorStyle = 'cursor-pointer';
} else if (options.coinSymbol) {
- clickAction = `onclick="window.location.href='/wallet/${options.coinSymbol}'"`;
+ clickAction = `onclick="window.location.href='/wallet/${options.coinSymbol.toLowerCase()}'"`;
cursorStyle = 'cursor-pointer';
} else if (options.releaseUrl) {
clickAction = `onclick="window.open('${options.releaseUrl}', '_blank')"`;
@@ -735,6 +736,18 @@ 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+$/, '');
+ const sweepTicker = data.ticker || data.coin_name;
+ toastTitle = `Swept ${sweepAmount} ${sweepTicker} to RPC wallet`;
+ toastOptions.subtitle = `Fee: ${sweepFee} ${sweepTicker} • TXID: ${(data.txid || '').substring(0, 12)}...`;
+ toastOptions.coinSymbol = sweepTicker;
+ 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..cb6b930
--- /dev/null
+++ b/basicswap/static/js/pages/bid-page.js
@@ -0,0 +1,614 @@
+const BidPage = {
+ bidId: null,
+ bidStateInd: null,
+ createdAtTimestamp: null,
+ autoRefreshInterval: null,
+ elapsedTimeInterval: null,
+ AUTO_REFRESH_SECONDS: 60,
+ refreshPaused: false,
+ swapType: null,
+ coinFrom: null,
+ coinTo: null,
+ previousStateInd: null,
+
+ INACTIVE_STATES: [8, 17, 18, 19, 21, 22, 23, 25, 31], // Completed, Failed variants, Timed-out, Abandoned, Error, Rejected, Expired
+
+ DELAYING_STATE: 20,
+
+ 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': null,
+ 'Bid Script coin spend tx valid': null,
+ 'Bid Scriptless coin locked': null,
+ 'Bid Script coin lock released': 'Adaptor signature revealed. The script coin can now be claimed',
+ 'Bid Script tx redeemed': null,
+ 'Bid Script pre-refund tx in chain': 'Pre-refund transaction detected. Swap may be failing',
+ 'Bid Scriptless tx redeemed': null,
+ 'Bid Scriptless tx recovered': null,
+ '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 adaptor 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'
+ },
+
+ getStateTooltip: function(stateText) {
+ const staticTooltip = this.STATE_TOOLTIPS[stateText];
+ if (staticTooltip !== null && staticTooltip !== undefined) {
+ return staticTooltip;
+ }
+
+ const scriptlessCoins = ['XMR', 'WOW'];
+ let scriptCoin, scriptlessCoin;
+
+ if (scriptlessCoins.includes(this.coinFrom)) {
+ scriptlessCoin = this.coinFrom;
+ scriptCoin = this.coinTo;
+ } else if (scriptlessCoins.includes(this.coinTo)) {
+ scriptlessCoin = this.coinTo;
+ scriptCoin = this.coinFrom;
+ } else {
+ scriptCoin = this.coinFrom;
+ scriptlessCoin = this.coinTo;
+ }
+
+ const dynamicTooltips = {
+ 'Bid Script coin locked': `${scriptCoin} is locked in the atomic swap contract`,
+ 'Bid Script coin spend tx valid': `The ${scriptCoin} spend transaction has been validated and is ready`,
+ 'Bid Scriptless coin locked': `${scriptlessCoin} is locked using adaptor signatures`,
+ 'Bid Script tx redeemed': `${scriptCoin} has been successfully claimed`,
+ 'Bid Scriptless tx redeemed': `${scriptlessCoin} has been successfully claimed`,
+ 'Bid Scriptless tx recovered': `${scriptlessCoin} recovered after swap failure`,
+ };
+
+ return dynamicTooltips[stateText] || null;
+ },
+
+ 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, options) {
+ this.bidId = bidId;
+ this.bidStateInd = bidStateInd;
+ this.createdAtTimestamp = createdAtTimestamp;
+ this.stateTimeTimestamp = stateTimeTimestamp;
+ this.tooltipCounter = 0;
+
+ options = options || {};
+ this.swapType = options.swapType || 'secret-hash';
+ this.coinFrom = options.coinFrom || '';
+ this.coinTo = options.coinTo || '';
+
+ if (this.bidStateInd === this.DELAYING_STATE) {
+ this.previousStateInd = this.findPreviousState();
+ }
+
+ this.applyStateTooltips();
+ this.applyEventTooltips();
+ this.createProgressBar();
+ this.startElapsedTimeUpdater();
+ this.setupAutoRefresh();
+ },
+
+ findPreviousState: function() {
+ const sections = document.querySelectorAll('section');
+ let oldStatesSection = null;
+
+ sections.forEach(section => {
+ const h4 = section.querySelector('h4');
+ if (h4 && h4.textContent.includes('Old states')) {
+ oldStatesSection = section.nextElementSibling;
+ }
+ });
+
+ if (oldStatesSection) {
+ const table = oldStatesSection.querySelector('table');
+ if (table) {
+ const rows = table.querySelectorAll('tr');
+
+ for (let i = rows.length - 1; i >= 0; i--) {
+ const cells = rows[i].querySelectorAll('td');
+ if (cells.length >= 2) {
+ const stateText = cells[cells.length - 1].textContent.trim();
+ if (!stateText.includes('Delaying')) {
+ return this.stateTextToIndex(stateText);
+ }
+ }
+ }
+ }
+ }
+ return null;
+ },
+
+ stateTextToIndex: function(stateText) {
+ const stateMap = {
+ 'Sent': 1, 'Receiving': 2, 'Received': 3, 'Receiving accept': 4,
+ 'Accepted': 5, 'Initiated': 6, 'Participating': 7, 'Completed': 8,
+ 'Script coin locked': 9, 'Script coin spend tx valid': 10,
+ 'Scriptless coin locked': 11, 'Script coin lock released': 12,
+ 'Script tx redeemed': 13, 'Script pre-refund tx in chain': 14,
+ 'Scriptless tx redeemed': 15, 'Scriptless tx recovered': 16,
+ 'Failed, refunded': 17, 'Failed, swiped': 18, 'Failed': 19,
+ 'Delaying': 20, 'Timed-out': 21, 'Abandoned': 22, 'Error': 23,
+ 'Rejected': 25, 'Exchanged script lock tx sigs msg': 27,
+ 'Exchanged script lock spend tx msg': 28, 'Request sent': 29,
+ 'Request accepted': 30, 'Expired': 31
+ };
+
+ for (const [key, value] of Object.entries(stateMap)) {
+ if (stateText.includes(key)) {
+ return value;
+ }
+ }
+ return null;
+ },
+
+ isActiveState: function() {
+ return !this.INACTIVE_STATES.includes(this.bidStateInd);
+ },
+
+ setupAutoRefresh: function() {
+ const refreshBtn = document.getElementById('refresh');
+ if (!refreshBtn) return;
+
+ if (!this.isActiveState()) {
+ refreshBtn.style.display = 'none';
+ return;
+ }
+
+ const originalSpan = refreshBtn.querySelector('span');
+ if (!originalSpan) return;
+
+ let countdown = this.AUTO_REFRESH_SECONDS;
+ let isRefreshing = false;
+ let isPersistentlyPaused = false;
+
+ const updateCountdown = () => {
+ if (this.refreshPaused || isPersistentlyPaused || isRefreshing) return;
+
+ originalSpan.textContent = `Auto-refresh in ${countdown}s`;
+ countdown--;
+
+ if (countdown < 0 && !isRefreshing) {
+ isRefreshing = true;
+ if (this.autoRefreshInterval) {
+ clearInterval(this.autoRefreshInterval);
+ this.autoRefreshInterval = null;
+ }
+ window.location.href = window.location.pathname + window.location.search;
+ }
+ };
+
+ updateCountdown();
+ this.autoRefreshInterval = setInterval(updateCountdown, 1000);
+
+ refreshBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+
+ if (isPersistentlyPaused) {
+ window.location.href = window.location.pathname + window.location.search;
+ } else {
+ isPersistentlyPaused = true;
+ if (this.autoRefreshInterval) {
+ clearInterval(this.autoRefreshInterval);
+ this.autoRefreshInterval = null;
+ }
+ originalSpan.textContent = 'Paused (click to refresh)';
+ }
+ });
+
+ refreshBtn.addEventListener('mouseenter', () => {
+ if (!isPersistentlyPaused) {
+ this.refreshPaused = true;
+ if (this.autoRefreshInterval) {
+ clearInterval(this.autoRefreshInterval);
+ this.autoRefreshInterval = null;
+ }
+ originalSpan.textContent = 'Click to pause';
+ }
+ });
+
+ refreshBtn.addEventListener('mouseleave', () => {
+ if (!isPersistentlyPaused) {
+ this.refreshPaused = false;
+ countdown = this.AUTO_REFRESH_SECONDS;
+ if (!this.autoRefreshInterval) {
+ updateCountdown();
+ this.autoRefreshInterval = setInterval(updateCountdown, 1000);
+ }
+ }
+ });
+ },
+
+ createTooltip: function(element, tooltipText) {
+ if (window.TooltipManager && typeof window.TooltipManager.create === 'function') {
+ try {
+ const tooltipContent = `
+
Funds Transfer Required
++ ${info.legacy_balance} ${info.coin} on non-derivable addresses will be automatically transferred to a BIP84 address. +
++ Est. fee: ${info.estimated_fee} ${info.coin} +
++ This ensures your funds are recoverable using the extended key backup in external Electrum wallets. +
+ ++ Some funds on non-derivable addresses (${info.legacy_balance} ${info.coin}) - too low to transfer. +
+ `; + } + + if (data.account_key) { + details.innerHTML = ` ++ IMPORTANT: Write down this key NOW. It will not be shown again. +
+Extended Private Key (for external wallet import):
+${'*'.repeat(Math.min(data.account_key.length, 80))}
+
+ To import in Electrum wallet:
+Before switching:
++ Note: Your balance will remain accessible - same seed means same funds in both modes. +
+ `; + if (confirmBtn) { + confirmBtn.disabled = false; + confirmBtn.classList.remove('opacity-50', 'cursor-not-allowed'); + } + } + } catch (error) { + console.error('Failed to fetch coin seed:', error); + details.innerHTML = ` +Failed to retrieve extended key. Please try again.
+Before switching:
+${info.error}
`; + } else if (info.balance_sats === 0) { + transferSection = `No funds to transfer.
`; + } else if (!info.can_transfer) { + transferSection = ` ++ Balance (${info.balance} ${info.coin}) is too low to transfer - fee would exceed funds. +
+ `; + } else { + transferSection = ` +Fund Transfer Options
++ Balance: ${info.balance} ${info.coin} | Est. fee: ${info.estimated_fee} ${info.coin} +
++ If you skip transfer, you will need to manually send funds from lite wallet addresses to your RPC wallet. +
+Switching to full node mode:
+Switching to full node mode:
+