diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index 8cdee59..f963ec0 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -2428,13 +2428,6 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): def _syncLiteWalletToRPCOnStartup(self, coin_type: Coins) -> None: try: - cc = self.coin_clients[coin_type] - if not cc.get("auto_transfer_on_mode_switch", True): - self.log.debug( - f"Auto-transfer disabled for {getCoinName(coin_type)}, skipping sweep check" - ) - return - cursor = self.openDB() try: row = cursor.execute( @@ -2780,6 +2773,137 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): 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}"} + + hrp = ci.chainparams_network().get("hrp", "bc") + legacy_utxos = [] + total_legacy_sats = 0 + + for u in unspent: + if "address" not in u: + continue + addr = u["address"] + if not addr.startswith(hrp + "1"): + 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})", + } + + try: + new_address = ci.rpc_wallet("getnewaddress", ["consolidate", "bech32"]) + 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 @@ -2869,6 +2993,43 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self.log.debug(f"getLiteWalletBalanceInfo error: {e}") return None + def getElectrumLegacyFundsInfo(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"getElectrumLegacyFundsInfo error: {e}") + return {"has_legacy_funds": False} + def _tryGetFullNodeAddresses(self, coin_type: Coins) -> list: addresses = [] try: @@ -13089,15 +13250,39 @@ 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" ) - migration_message = ( - f"Lite wallet ready for {display_name} ({count} addresses)." - ) + if migration_message: + migration_message += ( + f" Lite wallet ready ({count} addresses)." + ) + else: + migration_message = f"Lite wallet ready for {display_name} ({count} addresses)." else: error = migration_result.get("error", "unknown") reason = migration_result.get("reason", "") @@ -13120,11 +13305,38 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): empty_check = self._checkElectrumWalletEmpty(coin_id) if not empty_check.get("empty", False): - error = empty_check.get( - "message", "Wallet must be empty before switching modes" - ) reason = empty_check.get("reason", "") - if reason in ("has_balance", "active_swap"): + + 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 in ("has_balance", "active_swap"): + error = empty_check.get( + "message", "Wallet must be empty before switching modes" + ) self.log.error( f"Migration blocked for {coin_name}: {error}" ) @@ -13281,19 +13493,6 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): cc["electrum_onion_servers"] = new_onion break - if "auto_transfer_on_mode_switch" in data: - new_auto_transfer = data["auto_transfer_on_mode_switch"] - if ( - settings_cc.get("auto_transfer_on_mode_switch", True) - != new_auto_transfer - ): - settings_changed = True - settings_cc["auto_transfer_on_mode_switch"] = new_auto_transfer - for coin, cc in self.coin_clients.items(): - if cc["name"] == coin_name: - cc["auto_transfer_on_mode_switch"] = new_auto_transfer - break - if settings_changed: self._normalizeSettingsPaths(settings_copy) settings_path = os.path.join(self.data_dir, cfg.CONFIG_FILENAME) diff --git a/basicswap/bin/prepare.py b/basicswap/bin/prepare.py index e627260..1b075cc 100755 --- a/basicswap/bin/prepare.py +++ b/basicswap/bin/prepare.py @@ -1841,6 +1841,7 @@ def initialise_wallets( daemons = [] daemon_args = ["-noconnect", "-nodnsseed"] generated_mnemonic: bool = False + extended_keys = {} coins_failed_to_initialise = [] @@ -2098,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() @@ -2129,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): @@ -3071,7 +3098,7 @@ def main(): ) if particl_wallet_mnemonic != "none": - initialise_wallets( + extended_keys = initialise_wallets( None, { add_coin, @@ -3083,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/interface/btc.py b/basicswap/interface/btc.py index 19e3a8c..7bf6ea3 100644 --- a/basicswap/interface/btc.py +++ b/basicswap/interface/btc.py @@ -3355,7 +3355,8 @@ class BTCInterface(Secp256k1Interface): total_balance = sum(u.get("value", 0) for u in all_utxos) est_vsize = 10 + 31 + len(all_utxos) * 68 - est_fee = est_vsize * fee_per_vbyte + min_fee = 250 + est_fee = max(est_vsize * fee_per_vbyte, min_fee) if total_balance <= est_fee: raise ValueError( diff --git a/basicswap/js_server.py b/basicswap/js_server.py index 5af543a..e1ced37 100644 --- a/basicswap/js_server.py +++ b/basicswap/js_server.py @@ -1248,10 +1248,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), @@ -1674,6 +1676,97 @@ 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": + legacy_balance_sats = 0 + has_legacy_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: + rv["show_transfer_option"] = True + rv["legacy_balance_sats"] = legacy_balance_sats + rv["legacy_balance"] = ci.format_amount(legacy_balance_sats) + rv["message"] = ( + "Funds on legacy addresses - transfer recommended for external wallet compatibility" + ) + else: + rv["show_transfer_option"] = False + rv["legacy_balance_sats"] = 0 + rv["legacy_balance"] = "0" + if has_legacy_funds: + rv["message"] = "Legacy balance too low to transfer" + else: + rv["message"] = "All funds on native segwit 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) @@ -1805,6 +1898,7 @@ endpoints = { "coinhistory": js_coinhistory, "messageroutes": js_messageroutes, "electrumdiscover": js_electrum_discover, + "modeswitchinfo": js_modeswitchinfo, } diff --git a/basicswap/static/js/pages/settings-page.js b/basicswap/static/js/pages/settings-page.js index b0994e7..8c5bd84 100644 --- a/basicswap/static/js/pages/settings-page.js +++ b/basicswap/static/js/pages/settings-page.js @@ -147,6 +147,16 @@ hiddenInput.name = submitter.name; hiddenInput.value = submitter.value; form.appendChild(hiddenInput); + + const transferRadio = document.querySelector('input[name="transfer_choice"]:checked'); + if (transferRadio) { + const transferInput = document.createElement('input'); + transferInput.type = 'hidden'; + transferInput.name = `auto_transfer_now_${coinName}`; + transferInput.value = transferRadio.value === 'auto' ? 'true' : 'false'; + form.appendChild(transferInput); + } + form.submit(); } }); @@ -167,11 +177,26 @@ } }, - showWalletModeConfirmation: function(coinName, direction, submitter) { + updateConfirmButtonState: function() { + const confirmBtn = document.getElementById('walletModeConfirm'); + const checkbox = document.getElementById('walletModeKeyConfirmCheckbox'); + if (confirmBtn && checkbox) { + if (checkbox.checked) { + confirmBtn.disabled = false; + confirmBtn.classList.remove('opacity-50', 'cursor-not-allowed'); + } else { + confirmBtn.disabled = true; + confirmBtn.classList.add('opacity-50', 'cursor-not-allowed'); + } + } + }, + + showWalletModeConfirmation: async function(coinName, direction, submitter) { const modal = document.getElementById('walletModeModal'); const title = document.getElementById('walletModeTitle'); const message = document.getElementById('walletModeMessage'); const details = document.getElementById('walletModeDetails'); + const confirmBtn = document.getElementById('walletModeConfirm'); if (!modal || !title || !message || !details) return; @@ -179,37 +204,223 @@ const displayName = coinName.charAt(0).toUpperCase() + coinName.slice(1).toLowerCase(); - if (direction === 'lite') { - title.textContent = `Switch ${displayName} to Lite Wallet Mode`; - message.textContent = 'Please confirm you want to switch to lite wallet mode.'; - details.innerHTML = ` -
Before switching:
-- Note: Your balance will remain accessible - same seed means same funds in both modes. -
- `; - } else { - title.textContent = `Switch ${displayName} to Full Node Mode`; - message.textContent = 'Please confirm you want to switch to full node mode.'; - details.innerHTML = ` -Switching to full node mode:
-- Note: Your balance will remain accessible - same seed means same funds in both modes. -
- `; + details.innerHTML = ` +Legacy Address Funds Detected
++ ${info.legacy_balance} ${info.coin} on legacy addresses won't be visible in external Electrum wallet. + Est. fee: ${info.estimated_fee} ${info.coin} +
++ If you skip transfer, legacy funds won't be visible when importing the extended key into external Electrum wallet. +
++ Some funds on legacy 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):
+${data.account_key}
+ + This key can be imported into Electrum using "Use a master key" option. +
+ ${transferSection} +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:
+Legacy Address Funds
++ {{ legacy_funds_info.legacy_balance }} {{ legacy_funds_info.coin }} on legacy addresses won't be visible in external Electrum wallet. + To use funds with external wallets, transfer to a new address. +
+
+ Use the withdraw function below to send funds to a new {{ w.ticker | lower }}1... address.
+