diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index f963ec0..ccda06f 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -229,23 +229,35 @@ def checkAndNotifyBalanceChange( cc["cached_balance"] = current_balance cc["cached_total_balance"] = current_total_balance cc["cached_unconfirmed"] = current_unconfirmed - swap_client.log.debug( - f"{ci.ticker()} balance updated (trigger: {trigger_source})" - ) - balance_event = { - "event": "coin_balance_updated", - "coin": ci.ticker(), - "height": new_height, - "trigger": trigger_source, - } - swap_client.ws_server.send_message_to_all(json.dumps(balance_event)) + + 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}" ) - cc["cached_balance"] = None - cc["cached_total_balance"] = None - cc["cached_unconfirmed"] = None def threadPollXMRChainState(swap_client, coin_type): @@ -458,6 +470,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): 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 @@ -581,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"] @@ -736,24 +750,32 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self.delay_event.set() self.chainstate_delay_event.set() - if self._network: - self._network.stopNetwork() - self._network = None - - for coin_type, interface in self.coin_interfaces.items(): - if hasattr(interface, "_backend") and interface._backend is not None: + 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"): - interface._backend._server.disconnect() + 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 + self.log.info("Stopping threads.") 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) @@ -1927,15 +1949,29 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): cc["cached_total_balance"] = current_total cc["cached_unconfirmed"] = current_total - current_balance - balance_event = { - "event": "coin_balance_updated", - "coin": ci.ticker(), - "height": cc.get("chain_height", 0), - "trigger": "electrum_notification", - "address": address[:20] + "..." if address else None, - } - self.ws_server.send_message_to_all(json.dumps(balance_event)) - self.log.debug(f"Electrum notification: {ci.ticker()} balance updated") + 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}") @@ -2178,7 +2214,8 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): f"will sync keypool and trigger rescan in full node" ) return { - "empty": True, + "empty": False, + "reason": "has_balance", "has_balance": True, "balance_sats": balance_sats, "message": ( @@ -2495,9 +2532,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): else: self.log.warning(f"Sweep skipped for {coin_name}: {reason}") elif result.get("txid"): - self.log.info( - f"Sweep completed: {result.get('amount', 0) / 1e8:.8f} {coin_name} swept to RPC wallet" - ) + pass elif result.get("error"): self.log.warning( f"Sweep failed for {coin_name}: {result.get('error')}" @@ -2745,6 +2780,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): { "coin_type": int(coin_type), "coin_name": coin_name, + "ticker": chainparams[coin_type]["ticker"], "amount": send_amount / 1e8, "fee": fee / 1e8, "txid": txid_hex, @@ -2823,15 +2859,27 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): except Exception as e: return {"error": f"Failed to list UTXOs: {e}"} - hrp = ci.chainparams_network().get("hrp", "bc") + 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: + 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 not addr.startswith(hrp + "1"): + 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)) @@ -2855,10 +2903,26 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): "reason": f"Legacy balance ({total_legacy_sats}) too low for fee ({estimated_fee_sats})", } - try: - new_address = ci.rpc_wallet("getnewaddress", ["consolidate", "bech32"]) - except Exception as e: - return {"error": f"Failed to get new address: {e}"} + 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) @@ -2994,6 +3058,12 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): 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": @@ -3027,7 +3097,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): } return {"has_legacy_funds": False} except Exception as e: - self.log.debug(f"getElectrumLegacyFundsInfo error: {e}") + self.log.debug(f"_computeElectrumLegacyFundsInfo error: {e}") return {"has_legacy_funds": False} def _tryGetFullNodeAddresses(self, coin_type: Coins) -> list: @@ -4528,10 +4598,12 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self.log.info_s(f"In txn: {txid}") return txid - def withdrawLTC(self, type_from, value, addr_to, subfee: bool) -> str: - ci = self.ci(Coins.LTC) + def withdrawCoinExtended( + self, coin_type, type_from, value, addr_to, subfee: bool + ) -> str: + ci = self.ci(coin_type) self.log.info( - "withdrawLTC{}".format( + "withdrawCoinExtended{}".format( "" if self.log.safe_logs else " {} {} to {} {}".format( @@ -7454,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: @@ -7498,7 +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=0, + vout=found_tx.get("index", 0), ) if bid.xmr_b_lock_tx.txid != found_txid: self.log.debug( @@ -9328,6 +9401,95 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): 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") @@ -9526,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: @@ -9562,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) @@ -9594,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") @@ -11349,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)}." @@ -11413,7 +11599,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): bid_id=bid_id, tx_type=TxTypes.XMR_SWAP_B_LOCK, txid=b_lock_tx_id, - vout=0, + vout=b_lock_vout, ) xmr_swap.b_lock_tx_id = b_lock_tx_id bid.xmr_b_lock_tx.setState(TxStates.TX_SENT) @@ -12818,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: @@ -12891,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: @@ -13250,39 +13462,35 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): display_name = getCoinName(coin_id) if old_connection_type == "rpc" and new_connection_type == "electrum": - 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')}" - ) - if migration_message: - migration_message += f" Transferred {transfer_result.get('amount', 0):.8f} {display_name} to native segwit." - else: - 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')}" - ) - 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" ) - if migration_message: - migration_message += ( - f" Lite wallet ready ({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 ) - else: - migration_message = f"Lite wallet ready for {display_name} ({count} addresses)." + 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", "") @@ -13333,14 +13541,23 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): f"Transfer failed for {coin_name}: {error}" ) raise ValueError(f"Transfer failed: {error}") - elif reason in ("has_balance", "active_swap"): + elif reason == "active_swap": error = empty_check.get( - "message", "Wallet must be empty before switching modes" + "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"): @@ -13712,6 +13929,26 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): rv["mweb_pending"] = walletinfo.get( "mweb_unconfirmed", 0 ) + walletinfo.get("mweb_immature", 0) + elif coin == Coins.FIRO: + try: + rv["spark_address"] = self.getCachedStealthAddressForCoin( + Coins.FIRO + ) + except Exception as e: + self.log.warning( + f"getCachedStealthAddressForCoin for {ci.coin_name()} failed with: {e}." + ) + 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) + ) return rv except Exception as e: @@ -13719,9 +13956,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, @@ -13975,6 +14212,8 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): if row2[0].startswith("stealth"): if coin_id == Coins.LTC: wallet_data["mweb_address"] = row2[1] + elif coin_id == Coins.FIRO: + wallet_data["spark_address"] = row2[1] else: wallet_data["stealth_address"] = row2[1] else: diff --git a/basicswap/bin/run.py b/basicswap/bin/run.py index d67859f..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): @@ -618,7 +621,7 @@ def runClient( signal.CTRL_C_EVENT if os.name == "nt" else signal.SIGINT ) except Exception as e: - swap_client.log.info(f"Interrupting {d.name} {d.handle.pid}, error {e}") + swap_client.log.error(f"Interrupting {d.name} {d.handle.pid}: {e}") for d in daemons: try: d.handle.wait(timeout=120) @@ -627,10 +630,12 @@ def runClient( fp.close() closed_pids.append(d.handle.pid) except Exception as e: - swap_client.log.error(f"Error: {e}") + swap_client.log.error( + f"Waiting for {d.name} {d.handle.pid} to shutdown: {e}" + ) 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/http_server.py b/basicswap/http_server.py index ba30795..eb26ee4 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,73 @@ 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 7bf6ea3..db88b87 100644 --- a/basicswap/interface/btc.py +++ b/basicswap/interface/btc.py @@ -15,6 +15,7 @@ import os import shutil import sqlite3 import threading +import time import traceback from io import BytesIO @@ -185,6 +186,7 @@ def extractScriptLockRefundScriptValues(script_bytes: bytes): class BTCInterface(Secp256k1Interface): _scantxoutset_lock = threading.Lock() + _MAX_SCANTXOUTSET_RETRIES = 3 @staticmethod def coin_type(): @@ -233,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 @@ -759,6 +765,19 @@ class BTCInterface(Secp256k1Interface): 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) @@ -1890,8 +1909,9 @@ class BTCInterface(Secp256k1Interface): 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 > 1000: + if rough_change > dust_limit: change_addr = wm.getNewInternalAddress(self.coin_type()) if not change_addr: change_addr = wm.getExistingInternalAddress(self.coin_type()) @@ -1907,7 +1927,7 @@ class BTCInterface(Secp256k1Interface): final_fee = max(round(feerate_satkb * final_vsize / 1000), min_relay_fee) change = total_input - total_output - final_fee - if change > 1000: + if change > dust_limit: funded_tx.vout[-1].nValue = change else: funded_tx.vout.pop() @@ -2418,10 +2438,37 @@ 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): if self._connection_type == "electrum": return self._scanTxOutsetElectrum(dest) - return self.rpc("scantxoutset", ["start", ["raw({})".format(dest.hex())]]) + return self._rpc_scantxoutset(["raw({})".format(dest.hex())]) def _scanTxOutsetElectrum(self, dest): backend = self.getBackend() @@ -2572,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() @@ -2604,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) @@ -2646,44 +2699,66 @@ 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: - if self.useBackend(): - backend = self.getBackend() - tx_hex = backend.getTransactionRaw(chain_b_lock_txid.hex()) - if tx_hex: - lock_tx = self.loadTx(bytes.fromhex(tx_hex)) - locked_n = findOutput(lock_tx, script_pk) - if locked_n is None: - self._log.error( - f"spendBLockTx: Output not found in tx {chain_b_lock_txid.hex()}, " - f"script_pk={script_pk.hex()}, num_outputs={len(lock_tx.vout)}" - ) - for i, out in enumerate(lock_tx.vout): - self._log.debug( - f" vout[{i}]: value={out.nValue}, scriptPubKey={out.scriptPubKey.hex()}" - ) - else: - self._log.warning( - f"spendBLockTx: Failed to fetch tx {chain_b_lock_txid.hex()} from electrum, " - f"defaulting to vout=0 (standard for B lock transactions)" - ) - locked_n = 0 - else: - wtx = self.rpc_wallet_watch( - "gettransaction", - [ - chain_b_lock_txid.hex(), - ], - ) - lock_tx = self.loadTx(bytes.fromhex(wtx["hex"])) + locked_n = 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", + [ + 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].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() @@ -2699,16 +2774,14 @@ 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, prev_amount=cb_swap_value + b_lock_spend_tx, kbs, prev_amount=spend_value ) return bytes.fromhex(self.publishTx(b_lock_spend_tx)) @@ -3036,9 +3109,7 @@ class BTCInterface(Secp256k1Interface): 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: @@ -3492,13 +3563,12 @@ class BTCInterface(Secp256k1Interface): sum_unspent = 0 - with BTCInterface._scantxoutset_lock: - self._log.debug("scantxoutset start") - ro = self.rpc("scantxoutset", ["start", ["addr({})".format(address)]]) - self._log.debug("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"]) + for o in ro["unspents"]: + sum_unspent += self.make_int(o["amount"]) return sum_unspent def _getUTXOBalanceElectrum(self, address: str): @@ -3881,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 @@ -4005,7 +4075,9 @@ 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 == "": @@ -4038,12 +4110,9 @@ class BTCInterface(Secp256k1Interface): try: seed_id = self.getWalletSeedID() - self._log.debug( - f"{self.ticker()} unlockWallet getWalletSeedID returned: {seed_id}" - ) needs_seed_init = seed_id == "Not found" except Exception as e: - self._log.debug(f"getWalletSeedID failed: {e}, will initialize seed") + 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()}.") @@ -4051,11 +4120,9 @@ class BTCInterface(Secp256k1Interface): if password: self._log.info(f"Encrypting {self.coin_name()} wallet.") try: - self.rpc_wallet("encryptwallet", [password]) + 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: @@ -4072,7 +4139,7 @@ class BTCInterface(Secp256k1Interface): check_seed = False if self.isWalletEncrypted(): - self.rpc_wallet("walletpassphrase", [password, 100000000]) + self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120) if check_seed: self._sc.checkWalletSeed(self.coin_type()) @@ -4080,7 +4147,15 @@ class BTCInterface(Secp256k1Interface): self._log.info(f"lockWallet - {self.ticker()}") if self.useBackend(): return - self.rpc_wallet("walletlock") + 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) 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 c623465..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()) @@ -1064,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 @@ -1732,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 @@ -1764,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() @@ -1792,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 index 345d719..b8d27db 100644 --- a/basicswap/interface/electrumx.py +++ b/basicswap/interface/electrumx.py @@ -119,7 +119,8 @@ class ElectrumConnection: self._socket = None self._connected = False _close_socket_safe(sock) - for q in self._response_queues.values(): + queues = list(self._response_queues.values()) + for q in queues: try: q.put({"error": "Connection closed"}) except Exception: @@ -305,17 +306,26 @@ class ElectrumConnection: results = {} deadline = time.time() + timeout for req_id in expected_ids: - remaining = deadline - time.time() - if remaining <= 0: - raise TemporaryError("Batch request timed out") + 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: - response = self._response_queues[req_id].get(timeout=remaining) 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")} - except queue.Empty: - raise TemporaryError("Batch request timed out") finally: self._response_queues.pop(req_id, None) return results @@ -329,13 +339,13 @@ class ElectrumConnection: 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()) + 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: @@ -470,6 +480,7 @@ class ElectrumServer: self._connection = None self._current_server_idx = 0 self._lock = threading.Lock() + self._stopping = False self._server_version = None self._current_server_host = None @@ -492,17 +503,24 @@ class ElectrumServer: 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._bg_connection = None - self._bg_lock = threading.Lock() - self._bg_last_activity = 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 @@ -559,6 +577,8 @@ class ElectrumServer: 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: @@ -576,6 +596,8 @@ class ElectrumServer: 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 @@ -585,6 +607,7 @@ class ElectrumServer: 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( @@ -592,11 +615,15 @@ class ElectrumServer: f"({self._server_version}, {connect_time:.0f}ms)" ) self._initial_connection_logged = True - else: - self._log.debug( - f"Reconnected to Electrum server: {server['host']}:{server['port']} " + 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() @@ -609,8 +636,6 @@ class ElectrumServer: self._update_server_score(server, success=False) if self._is_rate_limit_error(str(e)): self._blacklist_server(server, str(e)) - if self._log: - self._log.debug(f"Failed to connect to {server['host']}: {e}") continue self._all_servers_failed = True raise TemporaryError( @@ -673,11 +698,6 @@ class ElectrumServer: key = self._get_server_key(s) if key in self._server_blacklist: if now < self._server_blacklist[key]: - if self._log: - remaining = int(self._server_blacklist[key] - now) - self._log.debug( - f"Skipping blacklisted server {key} ({remaining}s remaining)" - ) continue else: del self._server_blacklist[key] @@ -728,15 +748,14 @@ class ElectrumServer: if self._connection: self._connection._start_listener() result = self._connection.call( - "blockchain.headers.subscribe", [], timeout=10 + "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 as e: - if self._log: - self._log.debug(f"Failed to subscribe to headers: {e}") + except Exception: + pass def register_height_callback(self, callback): self._height_callback = callback @@ -744,6 +763,11 @@ class ElectrumServer: 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): { @@ -781,7 +805,8 @@ class ElectrumServer: return time.sleep(1) - if time.time() - self._last_activity >= self._keepalive_interval: + 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: @@ -802,6 +827,8 @@ class ElectrumServer: 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: @@ -824,17 +851,27 @@ class ElectrumServer: 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() @@ -851,17 +888,27 @@ class ElectrumServer: 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() @@ -877,7 +924,9 @@ class ElectrumServer: finally: self._lock.release() - def _connect_background(self): + def _connect_user(self): + if self._stopping: + return False sorted_servers = self.get_sorted_servers() for server in sorted_servers: try: @@ -890,105 +939,213 @@ class ElectrumServer: proxy_port=self._proxy_port, ) conn.connect() - self._bg_connection = conn + conn.get_server_version() + self._user_connection = conn + self._user_last_activity = time.time() if self._log: - self._log.debug( - f"Background connection established to {server['host']}" - ) + 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"Background connection failed to {server['host']}: {e}" - ) + self._log.debug(f"User connection failed to {server['host']}: {e}") continue return False - def call_background(self, method, params=None, timeout=10): - lock_acquired = self._bg_lock.acquire(timeout=1) + 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: - return self.call(method, params, timeout) + raise TemporaryError(f"User connection busy: {method}") try: - if self._bg_connection is None or not self._bg_connection.is_connected(): - if not self._connect_background(): - self._bg_lock.release() - return self.call(method, params, timeout) + 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._bg_connection.call(method, params, timeout=timeout) - self._bg_last_activity = time.time() + result = self._user_connection.call(method, params, timeout=timeout) + self._user_last_activity = time.time() return result - except Exception: - if self._bg_connection: + except Exception as e: + if self._log: + self._log.debug(f"User call failed ({method}): {e}") + if self._user_connection: try: - self._bg_connection.disconnect() + self._user_connection.disconnect() except Exception: pass - self._bg_connection = None + self._user_connection = None - if self._connect_background(): + if self._connect_user(): try: - result = self._bg_connection.call( + result = self._user_connection.call( method, params, timeout=timeout ) - self._bg_last_activity = time.time() + self._user_last_activity = time.time() return result - except Exception: - pass + except Exception as e2: + raise TemporaryError(f"User call failed: {e2}") - return self.call(method, params, timeout) + raise TemporaryError(f"User call failed: {e}") finally: - self._bg_lock.release() + self._user_lock.release() - def call_batch_background(self, requests, timeout=15): - lock_acquired = self._bg_lock.acquire(timeout=1) + 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: - return self.call_batch(requests, timeout) + raise TemporaryError("User connection busy") try: - if self._bg_connection is None or not self._bg_connection.is_connected(): - if not self._connect_background(): - self._bg_lock.release() - return self.call_batch(requests, timeout) + 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._bg_connection.call_batch(requests) - self._bg_last_activity = time.time() + result = self._user_connection.call_batch(requests) + self._user_last_activity = time.time() return result - except Exception: - if self._bg_connection: + except Exception as e: + if self._log: + self._log.debug(f"User batch call failed: {e}") + if self._user_connection: try: - self._bg_connection.disconnect() + self._user_connection.disconnect() except Exception: pass - self._bg_connection = None + self._user_connection = None - if self._connect_background(): + if self._connect_user(): try: - result = self._bg_connection.call_batch(requests) - self._bg_last_activity = time.time() + result = self._user_connection.call_batch(requests) + self._user_last_activity = time.time() return result - except Exception: - pass + except Exception as e2: + raise TemporaryError(f"User batch call failed: {e2}") - return self.call_batch(requests, timeout) + raise TemporaryError(f"User batch call failed: {e}") finally: - self._bg_lock.release() + self._user_lock.release() def disconnect(self): self._stop_keepalive() - with self._lock: - if self._connection: - self._connection.disconnect() - self._connection = None - with self._bg_lock: - if self._bg_connection: + 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: - self._bg_connection.disconnect() + conn.disconnect() except Exception: pass - self._bg_connection = None + 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]) diff --git a/basicswap/interface/firo.py b/basicswap/interface/firo.py index d536196..f31cbe8 100644 --- a/basicswap/interface/firo.py +++ b/basicswap/interface/firo.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2022-2023 tecnovert -# Copyright (c) 2024-2025 The Basicswap developers +# 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. @@ -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 @@ -102,6 +102,100 @@ class FIROInterface(BTCInterface): return addr_info["ismine"] return addr_info["ismine"] or addr_info["iswatchonly"] + def getNewSparkAddress(self) -> str: + try: + return self.rpc_wallet("getnewsparkaddress")[0] + except Exception as e: + self._log.error(f"getnewsparkaddress failed: {str(e)}") + raise + + def getNewStealthAddress(self): + """Get a new Spark address (alias for consistency with other coins).""" + return self.getNewSparkAddress() + + def getWalletInfo(self): + """Get wallet info including Spark balance.""" + rv = super(FIROInterface, self).getWalletInfo() + try: + spark_balance_info = self.rpc("getsparkbalance") + # getsparkbalance returns amounts in atomic units (satoshis) + # Field names: availableBalance, unconfirmedBalance, fullBalance + confirmed = spark_balance_info.get("availableBalance", 0) + unconfirmed = spark_balance_info.get("unconfirmedBalance", 0) + full_balance = spark_balance_info.get("fullBalance", 0) + # Values are already in atomic units, keep as integers + # basicswap.py will format them using format_amount + rv["spark_balance"] = confirmed if confirmed else 0 + rv["spark_unconfirmed"] = unconfirmed if unconfirmed else 0 + immature = full_balance - confirmed - unconfirmed + rv["spark_immature"] = immature if immature > 0 else 0 + except Exception as e: + self._log.warning(f"getsparkbalance failed: {str(e)}") + rv["spark_balance"] = 0 + rv["spark_unconfirmed"] = 0 + rv["spark_immature"] = 0 + return rv + + def createUTXO(self, value_sats: int): + # Create a new address and send value_sats to it + + spendable_balance = self.getSpendableBalance() + if spendable_balance < value_sats: + raise ValueError("Balance too low") + + address = self.getNewAddress(self._use_segwit, "create_utxo") + return ( + self.withdrawCoin(self.format_amount(value_sats), "plain", address, False), + address, + ) + + def withdrawCoin(self, value, type_from: str, addr_to: str, subfee: bool) -> str: + """Withdraw coins, supporting both transparent and Spark transactions. + + Args: + value: Amount to withdraw + type_from: "plain" for transparent, "spark" for Spark + addr_to: Destination address + subfee: Whether to subtract fee from amount + """ + type_to = "spark" if addr_to.startswith("sm1") else "plain" + + if "spark" in (type_from, type_to): + # RPC format: spendspark {"address": {"amount": ..., "subtractfee": ..., "memo": ...}} + # RPC wrapper will serialize this as: {"method": "spendspark", "params": [{...}], ...} + try: + if type_from == "spark": + # Construct params: dict where address is the key, wrapped in array for RPC + params = [ + {"address": addr_to, "amount": value, "subtractfee": subfee} + ] + result = self.rpc_wallet("spendspark", params) + else: + # Use automintspark to perform a plain -> spark tx of full balance + balance = self.rpc_wallet("getbalance") + if str(balance) == str(value): + result = self.rpc_wallet("automintspark") + else: + # subfee param is available on plain -> spark transactions + mint_params = {"amount": value} + if subfee: + mint_params["subfee"] = True + params = [{addr_to: mint_params}] + result = self.rpc_wallet("mintspark", params) + # spendspark returns a txid string directly, in a result dict, or as an array + if isinstance(result, list) and len(result) > 0: + return result[0] + if isinstance(result, dict): + return result.get("txid", result.get("tx", "")) + return result + except Exception as e: + self._log.error(f"spark tx failed: {str(e)}") + raise + else: + # Use standard sendtoaddress for transparent transactions + params = [addr_to, value, "", "", subfee] + return self.rpc_wallet("sendtoaddress", params) + def getSCLockScriptAddress(self, lock_script: bytes) -> str: lock_tx_dest = self.getScriptDest(lock_script) address = self.encodeScriptDest(lock_tx_dest) @@ -252,10 +346,6 @@ class FIROInterface(BTCInterface): assert len(script_hash) == 20 return CScript([OP_HASH160, script_hash, OP_EQUAL]) - def withdrawCoin(self, value, addr_to, subfee): - params = [addr_to, value, "", "", subfee] - return self.rpc("sendtoaddress", params) - def getWalletSeedID(self): return self.rpc("getwalletinfo")["hdmasterkeyid"] diff --git a/basicswap/interface/ltc.py b/basicswap/interface/ltc.py index 5d7bf83..897f69b 100644 --- a/basicswap/interface/ltc.py +++ b/basicswap/interface/ltc.py @@ -209,11 +209,9 @@ class LTCInterface(BTCInterface): try: seed_id = self.getWalletSeedID() - self._log.debug(f"LTC unlockWallet getWalletSeedID returned: {seed_id}") needs_seed_init = seed_id == "Not found" except Exception as e: - - self._log.debug(f"getWalletSeedID failed: {e}, will initialize seed") + 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()}.") @@ -221,7 +219,7 @@ class LTCInterface(BTCInterface): if password: self._log.info(f"Encrypting {self.coin_name()} wallet.") try: - self.rpc_wallet("encryptwallet", [password]) + self.rpc_wallet("encryptwallet", [password], timeout=120) except Exception as e: self._log.debug(f"encryptwallet returned: {e}") import time @@ -242,7 +240,7 @@ class LTCInterface(BTCInterface): check_seed = False if self.isWalletEncrypted(): - self.rpc_wallet("walletpassphrase", [password, 100000000]) + self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120) if check_seed: self._sc.checkWalletSeed(self.coin_type()) @@ -332,7 +330,7 @@ class LTCInterfaceMWEB(LTCInterface): 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()) @@ -341,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: @@ -355,15 +353,12 @@ class LTCInterfaceMWEB(LTCInterface): if not self.has_mweb_wallet(): self.init_wallet(password) else: - self.rpc_wallet("walletpassphrase", [password, 100000000]) + self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120) try: seed_id = self.getWalletSeedID() - self._log.debug( - f"LTC_MWEB unlockWallet getWalletSeedID returned: {seed_id}" - ) needs_seed_init = seed_id == "Not found" except Exception as e: - self._log.debug(f"getWalletSeedID failed: {e}, will initialize seed") + 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()}.") 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/js_server.py b/basicswap/js_server.py index 3c7a921..15e1306 100644 --- a/basicswap/js_server.py +++ b/basicswap/js_server.py @@ -79,9 +79,11 @@ def withdraw_coin(swap_client, coin_type, post_string, is_json): txid_hex = swap_client.withdrawParticl( type_from, type_to, value, address, subfee ) - elif coin_type == Coins.LTC: + elif coin_type in (Coins.LTC, Coins.FIRO): type_from = get_data_entry_or(post_data, "type_from", "plain") - txid_hex = swap_client.withdrawLTC(type_from, value, address, subfee) + txid_hex = swap_client.withdrawCoinExtended( + coin_type, type_from, value, address, subfee + ) elif coin_type in (Coins.XMR, Coins.WOW): txid_hex = swap_client.withdrawCoin(coin_type, value, address, sweepall) else: @@ -181,6 +183,15 @@ def js_walletbalances(self, url_split, post_string, is_json) -> bytes: 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) @@ -1254,7 +1265,6 @@ def js_getcoinseed(self, url_split, post_string, is_json) -> bytes: wallet_seed_id = f"Error: {e}" rv.update( { - "seed": seed_key.hex(), "seed_id": seed_id.hex(), "expected_seed_id": "Unset" if expect_seedid is None else expect_seedid, "current_seed_id": wallet_seed_id, @@ -1739,38 +1749,57 @@ def js_modeswitchinfo(self, url_split, post_string, is_json) -> bytes: } if direction == "lite": - legacy_balance_sats = 0 - has_legacy_funds = False + non_bip84_balance_sats = 0 + has_non_bip84_funds = False try: if hasattr(ci, "rpc_wallet"): unspent = ci.rpc_wallet("listunspent") - hrp = ci.chainparams_network().get("hrp", "bc") - for u in unspent: - if "address" in u and not u["address"].startswith(hrp + "1"): - legacy_balance_sats += ci.make_int(u.get("amount", 0)) - has_legacy_funds = True - except Exception as e: - swap_client.log.debug(f"Error checking legacy addresses: {e}") - if has_legacy_funds and legacy_balance_sats > min_viable: + 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"] = legacy_balance_sats - rv["legacy_balance"] = ci.format_amount(legacy_balance_sats) + rv["legacy_balance_sats"] = non_bip84_balance_sats + rv["legacy_balance"] = ci.format_amount(non_bip84_balance_sats) rv["message"] = ( - "Funds on legacy addresses must be transferred for external wallet compatibility" + "Funds on non-derivable addresses must be transferred for external wallet compatibility" ) else: rv["show_transfer_option"] = False rv["require_transfer"] = False - if has_legacy_funds: - rv["legacy_balance_sats"] = legacy_balance_sats - rv["legacy_balance"] = ci.format_amount(legacy_balance_sats) - rv["message"] = "Legacy balance too low to transfer" + 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 native segwit addresses" + rv["message"] = "All funds on BIP84 addresses" else: rv["show_transfer_option"] = can_transfer if balance_sats == 0: 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/notification-manager.js b/basicswap/static/js/modules/notification-manager.js index 91c0a98..30c5d10 100644 --- a/basicswap/static/js/modules/notification-manager.js +++ b/basicswap/static/js/modules/notification-manager.js @@ -610,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')"`; @@ -739,9 +739,10 @@ function ensureToastContainer() { case 'sweep_completed': const sweepAmount = parseFloat(data.amount || 0).toFixed(8).replace(/\.?0+$/, ''); const sweepFee = parseFloat(data.fee || 0).toFixed(8).replace(/\.?0+$/, ''); - toastTitle = `Swept ${sweepAmount} ${data.coin_name} to RPC wallet`; - toastOptions.subtitle = `Fee: ${sweepFee} ${data.coin_name} • TXID: ${(data.txid || '').substring(0, 12)}...`; - toastOptions.coinSymbol = data.coin_name; + 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; diff --git a/basicswap/static/js/pages/bids-available-page.js b/basicswap/static/js/pages/bids-available-page.js index fa49168..c12967f 100644 --- a/basicswap/static/js/pages/bids-available-page.js +++ b/basicswap/static/js/pages/bids-available-page.js @@ -1,7 +1,7 @@ const PAGE_SIZE = 50; const state = { - dentities: new Map(), + identities: new Map(), currentPage: 1, wsConnected: false, jsonData: [], diff --git a/basicswap/static/js/pages/settings-page.js b/basicswap/static/js/pages/settings-page.js index b26b289..f8a0cdd 100644 --- a/basicswap/static/js/pages/settings-page.js +++ b/basicswap/static/js/pages/settings-page.js @@ -251,15 +251,15 @@ let transferSection = ''; if (info.require_transfer && info.legacy_balance_sats > 0) { transferSection = ` -
Legacy Funds Transfer Required
-- ${info.legacy_balance} ${info.coin} on legacy addresses will be automatically transferred to a native segwit address. +
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.
@@ -267,8 +267,8 @@ `; } else if (info.legacy_balance_sats > 0 && !info.show_transfer_option) { transferSection = ` -- Some funds on legacy addresses (${info.legacy_balance} ${info.coin}) - too low to transfer. +
+ Some funds on non-derivable addresses (${info.legacy_balance} ${info.coin}) - too low to transfer.
`; } @@ -280,11 +280,22 @@Extended Private Key (for external wallet import):
${data.account_key}
+ ${'*'.repeat(Math.min(data.account_key.length, 80))}
+
+ To import in Electrum wallet:
+- This key can be imported into Electrum using "Use a master key" option. -
${transferSection}+
If you skip transfer, you will need to manually send funds from lite wallet addresses to your RPC wallet.
To Light Wallet:
+Light Wallet Mode (Electrum):
While in Light Wallet mode:
+Full Node Mode (RPC):
To Full Node:
+When switching modes:
Spark Balance:
Spark Balance: