diff --git a/basicswap/interface/ltc.py b/basicswap/interface/ltc.py index 6450198..63b6217 100644 --- a/basicswap/interface/ltc.py +++ b/basicswap/interface/ltc.py @@ -92,7 +92,9 @@ class LTCInterface(BTCInterface): if "address" not in u: continue utxo_address: str = u["address"] - if any(utxo_address.startswith(prefix) for prefix in ("mweb1", "tmweb1")): + if any( + utxo_address.startswith(prefix) for prefix in ("ltcmweb1", "tmweb1") + ): continue if "desc" in u: desc = u["desc"] @@ -111,6 +113,76 @@ class LTCInterface(BTCInterface): ) + self.make_int(u["amount"], r=1) return unspent_addr + def getMWEBBalance(self) -> int: + if self.useBackend(): + raise ValueError("MWEB not supported in electrum mode") + + value: int = 0 + unspent = self.rpc_wallet( + "listunspent", + [ + 0, + ], + ) + for u in unspent: + if "address" not in u: + continue + utxo_address: str = u["address"] + if any( + utxo_address.startswith(prefix) for prefix in ("ltcmweb1", "tmweb1") + ): + value += self.make_int(u["amount"], r=1) + return value + + def convertMWEBBalance(self): + if self.useBackend(): + raise ValueError("MWEB not supported in electrum mode") + + self._log.info(f"convertMWEBBalance - {self.ticker()}") + locked_before = self.rpc_wallet("listlockunspent") + lock_utxos = [] + try: + # Hack: mark all the other utxos as unspendable, alternative is to use a mweb_transfer wallet + utxos = self.rpc_wallet("listunspent") + mweb_amount: int = 0 + for utxo in utxos: + utxo_address: str = utxo.get("address", "") + if any( + utxo_address.startswith(prefix) for prefix in ("ltcmweb1", "tmweb1") + ): + mweb_amount += self.make_int(utxo["amount"], r=1) + continue + utxo_op = {"txid": utxo["txid"], "vout": utxo["vout"]} + if utxo_op in locked_before: + continue + lock_utxos.append(utxo_op) + + if mweb_amount == 0: + raise ValueError("No MWEB outputs to convert") + self.rpc_wallet("lockunspent", [False, lock_utxos]) + subfee_to_mweb: bool = True + convert_value = self.format_amount(mweb_amount) + plain_addr: str = self.rpc_wallet("getnewaddress", ["transfer", "bech32"]) + + # Double check generated address is owned by this wallet + if not self.isAddressMine(plain_addr): + raise ValueError("Generated address not owned by wallet!") + params = [ + plain_addr, + convert_value, + "", + "", + subfee_to_mweb, + True, + self._conf_target, + ] + txid = self.rpc_wallet("sendtoaddress", params) + + self._log.info(f"MWEB in plain converted in txid: {self._log.id(txid)}") + return txid + finally: + self.rpc_wallet("lockunspent", [True, lock_utxos]) + def unlockWallet(self, password: str, check_seed: bool = True) -> None: if password == "": return diff --git a/basicswap/js_server.py b/basicswap/js_server.py index fa83cae..af60043 100644 --- a/basicswap/js_server.py +++ b/basicswap/js_server.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020-2024 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. @@ -129,7 +129,6 @@ def js_walletbalances(self, url_split, post_string, is_json) -> bytes: swap_client = self.server.swap_client try: - swap_client.updateWalletsInfo() wallets = swap_client.getCachedWalletsInfo() coins_with_balances = [] @@ -332,6 +331,18 @@ def js_wallets(self, url_split, post_string, is_json): return bytes( json.dumps(swap_client.ci(coin_type).getNewMwebAddress()), "UTF-8" ) + elif cmd == "mwebbalance": + # mweb outputs left behind when sending LTC -> MWEB + if coin_type not in (Coins.LTC,): + raise ValueError("Invalid coin for command") + ci = swap_client.ci(coin_type) + return bytes(json.dumps(ci.format_amount(ci.getMWEBBalance())), "UTF-8") + elif cmd == "convertmweb": + if coin_type not in (Coins.LTC,): + raise ValueError("Invalid coin for command") + return bytes( + json.dumps(swap_client.ci(coin_type).convertMWEBBalance()), "UTF-8" + ) elif cmd == "watchaddress": post_data = getFormData(post_string, is_json) address = get_data_entry(post_data, "address") diff --git a/basicswap/static/js/modules/event-handlers.js b/basicswap/static/js/modules/event-handlers.js index 1f96c8e..35557e8 100644 --- a/basicswap/static/js/modules/event-handlers.js +++ b/basicswap/static/js/modules/event-handlers.js @@ -40,6 +40,10 @@ }); }, + confirmMWEBChangeConvert: function() { + return confirm('Confirm MWEB change conversion: This will create a tx sending all spendable MWEB outputs in the plain LTC wallet to LTC.'); + }, + confirmReseed: function() { return confirm('Are you sure you want to reseed the wallet? This will generate new addresses.'); }, @@ -60,7 +64,7 @@ }, fillDonationAddress: function(address, coinType) { - + let addressInput = null; addressInput = window.DOMCache @@ -188,7 +192,7 @@ }, lookup_rates: function() { - + if (window.lookup_rates && typeof window.lookup_rates === 'function') { window.lookup_rates(); } else { @@ -282,6 +286,16 @@ } }); + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-confirm-mweb-change-convert]'); + if (target) { + if (!this.confirmMWEBChangeConvert()) { + e.preventDefault(); + return false; + } + } + }); + document.addEventListener('click', (e) => { const target = e.target.closest('[data-confirm-utxo]'); if (target) { @@ -398,6 +412,7 @@ window.EventHandlers = EventHandlers; window.confirmReseed = EventHandlers.confirmReseed.bind(EventHandlers); + window.confirmMWEBChangeConvert = EventHandlers.confirmMWEBChangeConvert.bind(EventHandlers); window.confirmWithdrawal = EventHandlers.confirmWithdrawal.bind(EventHandlers); window.confirmUTXOResize = EventHandlers.confirmUTXOResize.bind(EventHandlers); window.confirmRemoveExpired = EventHandlers.confirmRemoveExpired.bind(EventHandlers); diff --git a/basicswap/templates/wallet.html b/basicswap/templates/wallet.html index c21026f..5df2a53 100644 --- a/basicswap/templates/wallet.html +++ b/basicswap/templates/wallet.html @@ -185,6 +185,17 @@ {% endif %} + {% if w.mweb_in_plain %} + + {{ w.name }} MWEB MWEB in Plain Balance: + + {{ w.mweb_in_plain }} {{ w.ticker }} + + + + + + {% endif %} {% elif w.cid == '13' %} {# FIRO #} {{ w.name }} Spark Spark Balance: diff --git a/basicswap/ui/page_wallet.py b/basicswap/ui/page_wallet.py index 449d349..ae4fbae 100644 --- a/basicswap/ui/page_wallet.py +++ b/basicswap/ui/page_wallet.py @@ -273,6 +273,9 @@ def page_wallet(self, url_split, post_string): swap_client.cacheNewAddressForCoin(coin_id) elif have_data_entry(form_data, "forcerefresh"): force_refresh = True + elif have_data_entry(form_data, "convertmweb_" + cid): + txid = swap_client.ci(coin_id).convertMWEBBalance() + messages.append(f"Converted MWEB change to LTC in tx: {txid}") elif have_data_entry(form_data, "newmwebaddr_" + cid): swap_client.cacheNewStealthAddressForCoin(coin_id) elif have_data_entry(form_data, "newsparkaddr_" + cid): @@ -525,6 +528,10 @@ def page_wallet(self, url_split, post_string): // page_data["fee_estimate"]["sum_weight"] ) + if k == Coins.LTC and ci.useBackend() is False: + mweb_value: int = ci.getMWEBBalance() + if mweb_value > 0: + wallet_data["mweb_in_plain"] = ci.format_amount(mweb_value) if show_utxo_groups: utxo_groups = "" unspent_by_addr = ci.getUnspentsByAddr() diff --git a/doc/coins/ltc.md b/doc/coins/ltc.md new file mode 100644 index 0000000..a5300c3 --- /dev/null +++ b/doc/coins/ltc.md @@ -0,0 +1,7 @@ +# LTC Notes + +## MWEB + +Sending LTC -> MWEB generates MWEB change outputs in the plain LTC wallet that BSX can't use. +A temporary convenience function is provided to convert those MWEB outputs back to plain LTC. + diff --git a/tests/basicswap/extended/test_xmr_persistent.py b/tests/basicswap/extended/test_xmr_persistent.py index d943f8e..b893a6a 100644 --- a/tests/basicswap/extended/test_xmr_persistent.py +++ b/tests/basicswap/extended/test_xmr_persistent.py @@ -15,14 +15,15 @@ export XMR_RPC_USER=xmr_user export XMR_RPC_PWD=xmr_pwd python tests/basicswap/extended/test_xmr_persistent.py - # Copy coin releases to permanent storage for faster subsequent startups cp -r ${TEST_PATH}/bin/* ~/tmp/basicswap_bin/ - # Continue existing chains with export RESET_TEST=false +# Set coins started +export TEST_COINS_LIST="bitcoin,monero,litecoin" + """ import json diff --git a/tests/basicswap/test_ltc_xmr.py b/tests/basicswap/test_ltc_xmr.py index 4fc38f9..9ab9ff8 100644 --- a/tests/basicswap/test_ltc_xmr.py +++ b/tests/basicswap/test_ltc_xmr.py @@ -268,6 +268,62 @@ class TestLTC(BasicSwapTest): if "mweb1" in addr: raise ValueError("getUnspentsByAddr should exclude mweb UTXOs.") + # Test helper functions to convert MWEB change + mweb_change_value = ci0.getMWEBBalance() + assert mweb_change_value > 0 + + test_lock_utxo = None + for utxo in utxos: + utxo_address: str = utxo.get("address", "") + if any( + utxo_address.startswith(prefix) for prefix in ("ltcmweb1", "tmweb1") + ): + continue + test_lock_utxo = {"txid": utxo["txid"], "vout": utxo["vout"]} + ci0.rpc_wallet( + "lockunspent", + [ + False, + [ + test_lock_utxo, + ], + ], + ) + break + assert len(ci0.rpc_wallet("listlockunspent")) == 1 + + txid = ci0.convertMWEBBalance() + + # Check utxos locked before conversion are still locked after + assert len(ci0.rpc_wallet("listlockunspent")) == 1 + ci0.rpc_wallet( + "lockunspent", + [ + True, + [ + test_lock_utxo, + ], + ], + ) + assert len(ci0.rpc_wallet("listlockunspent")) == 0 + + txj = ci0.rpc_wallet( + "gettransaction", + [ + txid, + ], + ) + assert len(txj["details"]) == 2 + + fee_amt = -ci0.make_int(txj["fee"]) + assert txj["details"][0]["category"] == "send" + assert ci0.make_int(txj["details"][0]["amount"]) - fee_amt == -mweb_change_value + assert txj["details"][1]["category"] == "receive" + assert ci0.make_int(txj["details"][1]["amount"]) + fee_amt == mweb_change_value + + mweb_change_value = ci0.getMWEBBalance() + assert mweb_change_value == 0 + # TODO def test_22_mweb_balance(self): @@ -362,6 +418,20 @@ class TestLTC(BasicSwapTest): json_rv = read_json_api(TEST_HTTP_PORT + 0, "wallets/ltc", post_json) assert json_rv["mweb_balance"] <= 20.0 + # Test helper functions to convert MWEB change + json_rv = read_json_api( + TEST_HTTP_PORT + 0, "wallets/ltc/mwebbalance", post_json + ) + assert float(json_rv) > 0 + json_rv = read_json_api( + TEST_HTTP_PORT + 0, "wallets/ltc/convertmweb", post_json + ) + assert len(json_rv) == 64 + json_rv = read_json_api( + TEST_HTTP_PORT + 0, "wallets/ltc/mwebbalance", post_json + ) + assert float(json_rv) == 0 + if __name__ == "__main__": unittest.main()