Electrum connection stability, swap fixes / UX improvements + Various fixes.

This commit is contained in:
gerlofvanek
2026-03-31 22:10:25 +02:00
parent 3258b76a49
commit 129a5bb9b7
27 changed files with 1383 additions and 413 deletions
+330 -91
View File
@@ -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:
+22 -17
View File
@@ -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:
+68 -2
View File
@@ -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:
+140 -65
View File
@@ -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)
+4 -2
View File
@@ -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
)
+60 -20
View File
@@ -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)
+245 -88
View File
@@ -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])
+96 -6
View File
@@ -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"]
+7 -12
View File
@@ -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()}.")
+1 -1
View File
@@ -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
+50 -21
View File
@@ -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:
+24 -5
View File
@@ -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
@@ -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;
@@ -1,7 +1,7 @@
const PAGE_SIZE = 50;
const state = {
dentities: new Map(),
identities: new Map(),
currentPage: 1,
wsConnected: false,
jsonData: [],
+43 -15
View File
@@ -251,15 +251,15 @@
let transferSection = '';
if (info.require_transfer && info.legacy_balance_sats > 0) {
transferSection = `
<div class="bg-yellow-100 dark:bg-yellow-900/50 border border-yellow-400 dark:border-yellow-600 rounded-lg p-3 mb-3">
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200 mb-2">Legacy Funds Transfer Required</p>
<p class="text-xs text-gray-700 dark:text-gray-300 mb-2">
<strong>${info.legacy_balance} ${info.coin}</strong> on legacy addresses will be automatically transferred to a native segwit address.
<div class="bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-500 rounded-lg p-3 mb-3">
<p class="text-sm font-medium text-gray-900 dark:text-white mb-2">Funds Transfer Required</p>
<p class="text-xs text-gray-700 dark:text-gray-200 mb-2">
<strong>${info.legacy_balance} ${info.coin}</strong> on non-derivable addresses will be automatically transferred to a BIP84 address.
</p>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-2">
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">
Est. fee: ${info.estimated_fee} ${info.coin}
</p>
<p class="text-xs text-gray-700 dark:text-gray-300">
<p class="text-xs text-gray-700 dark:text-gray-200">
This ensures your funds are recoverable using the extended key backup in external Electrum wallets.
</p>
<input type="hidden" name="transfer_choice" value="auto">
@@ -267,8 +267,8 @@
`;
} else if (info.legacy_balance_sats > 0 && !info.show_transfer_option) {
transferSection = `
<p class="text-yellow-700 dark:text-yellow-300 text-xs mb-3">
Some funds on legacy addresses (${info.legacy_balance} ${info.coin}) - too low to transfer.
<p class="text-gray-700 dark:text-gray-300 text-xs mb-3">
Some funds on non-derivable addresses (${info.legacy_balance} ${info.coin}) - too low to transfer.
</p>
`;
}
@@ -280,11 +280,22 @@
</p>
<p class="mb-2 text-gray-800 dark:text-gray-100"><strong>Extended Private Key (for external wallet import):</strong></p>
<div class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-500 rounded p-2 mb-3">
<code class="text-xs break-all select-all font-mono text-gray-900 dark:text-gray-100">${data.account_key}</code>
<code id="extendedKeyDisplay" class="text-xs break-all font-mono text-gray-900 dark:text-gray-100">${'*'.repeat(Math.min(data.account_key.length, 80))}</code>
<code id="extendedKeyActual" class="text-xs break-all select-all font-mono text-gray-900 dark:text-gray-100 hidden">${data.account_key}</code>
</div>
<div class="mb-3">
<button type="button" id="toggleKeyVisibility" class="px-3 py-1 text-xs bg-blue-500 hover:bg-blue-600 text-white rounded">
Show Key
</button>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300 mb-3 bg-gray-100 dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded p-2">
<p class="font-medium mb-1 text-gray-800 dark:text-gray-100">To import in Electrum wallet:</p>
<ol class="list-decimal list-inside space-y-0.5">
<li>Open Electrum File New/Restore</li>
<li>Choose "Standard wallet" "Use a master key"</li>
<li>Paste this key (starts with zprv... or yprv...)</li>
</ol>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-3">
This key can be imported into Electrum using "Use a master key" option.
</p>
${transferSection}
<div class="border-t border-gray-300 dark:border-gray-500 pt-3">
<label class="flex items-center cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-500 rounded p-1 -m-1">
@@ -294,6 +305,23 @@
</div>
`;
const toggleBtn = document.getElementById('toggleKeyVisibility');
const keyDisplay = document.getElementById('extendedKeyDisplay');
const keyActual = document.getElementById('extendedKeyActual');
if (toggleBtn && keyDisplay && keyActual) {
toggleBtn.addEventListener('click', () => {
if (keyDisplay.classList.contains('hidden')) {
keyDisplay.classList.remove('hidden');
keyActual.classList.add('hidden');
toggleBtn.textContent = 'Show Key';
} else {
keyDisplay.classList.add('hidden');
keyActual.classList.remove('hidden');
toggleBtn.textContent = 'Hide Key';
}
});
}
const checkbox = document.getElementById('walletModeKeyConfirmCheckbox');
if (checkbox) {
checkbox.addEventListener('change', () => this.updateConfirmButtonState());
@@ -362,21 +390,21 @@
</p>
<div class="space-y-2">
<label class="flex items-start cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 rounded p-1.5 -m-1">
<input type="radio" name="transfer_choice" value="auto" class="mt-0.5 mr-2 h-4 w-4 text-blue-600 border-gray-400 dark:border-gray-400 focus:ring-blue-500 bg-white dark:bg-gray-500">
<input type="radio" name="transfer_choice" value="auto" checked class="mt-0.5 mr-2 h-4 w-4 text-blue-600 border-gray-400 dark:border-gray-400 focus:ring-blue-500 bg-white dark:bg-gray-500">
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">Auto-transfer funds to RPC wallet</span>
<p class="text-xs text-gray-600 dark:text-gray-300">Recommended. Ensures all funds visible in full node wallet.</p>
</div>
</label>
<label class="flex items-start cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 rounded p-1.5 -m-1">
<input type="radio" name="transfer_choice" value="manual" checked class="mt-0.5 mr-2 h-4 w-4 text-blue-600 border-gray-400 dark:border-gray-400 focus:ring-blue-500 bg-white dark:bg-gray-500">
<input type="radio" name="transfer_choice" value="manual" class="mt-0.5 mr-2 h-4 w-4 text-blue-600 border-gray-400 dark:border-gray-400 focus:ring-blue-500 bg-white dark:bg-gray-500">
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">Keep funds on current addresses</span>
<p class="text-xs text-gray-600 dark:text-gray-300">Transfer manually later if needed.</p>
</div>
</label>
</div>
<p class="text-xs text-red-600 dark:text-red-400 mt-3">
<p class="text-xs text-gray-600 dark:text-gray-400 mt-3">
If you skip transfer, you will need to manually send funds from lite wallet addresses to your RPC wallet.
</p>
</div>
+23 -7
View File
@@ -87,7 +87,7 @@
}
}
if (coinData.scan_status) {
if (coinData.scan_status || coinData.electrum_synced !== undefined) {
this.updateScanStatus(coinData);
}
@@ -110,7 +110,7 @@
if (!scanStatusEl) return;
const status = coinData.scan_status;
if (status.in_progress) {
if (status && status.in_progress) {
scanStatusEl.innerHTML = `
<div class="flex items-center justify-between text-xs">
<span class="text-blue-600 dark:text-blue-300">
@@ -126,13 +126,29 @@
<div class="bg-blue-600 dark:bg-blue-400 h-1 rounded-full transition-all" style="width: ${status.progress}%"></div>
</div>
`;
} else if (coinData.electrum_synced) {
const height = coinData.electrum_height || '';
scanStatusEl.innerHTML = `
<div class="bg-green-50 dark:bg-gray-500 p-2 rounded">
<div class="flex items-center text-xs text-green-600 dark:text-green-400">
Electrum Wallet Synced (${height})
</div>
</div>
`;
} else if (coinData.electrum_synced === false) {
scanStatusEl.innerHTML = `
<div class="bg-yellow-50 dark:bg-gray-500 p-2 rounded">
<div class="flex items-center text-xs text-yellow-600 dark:text-yellow-400">
Waiting for Electrum Server...
</div>
</div>
`;
} else {
scanStatusEl.innerHTML = `
<div class="flex items-center text-xs text-green-600 dark:text-green-400">
<svg class="inline-block w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Electrum Wallet Synced
<div class="bg-green-50 dark:bg-gray-500 p-2 rounded">
<div class="flex items-center text-xs text-green-600 dark:text-green-400">
Electrum Wallet Synced
</div>
</div>
`;
}
+29 -28
View File
@@ -167,29 +167,31 @@
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 space-y-4">
<div>
<p class="text-xs font-medium text-gray-900 dark:text-white mb-1">To Light Wallet:</p>
<p class="text-xs font-medium text-gray-900 dark:text-white mb-1">Light Wallet Mode (Electrum):</p>
<ul class="text-xs text-gray-700 dark:text-gray-200 space-y-0.5 ml-3">
<li>Your full node stops running</li>
<li>Light wallet uses your seed to access existing funds</li>
<li>No transfer needed - same seed, same funds</li>
<li>No blockchain download needed - connect via external Electrum servers</li>
<li>Uses BIP84 derivation (native SegWit) - lower fees, modern addresses (bc1q.../ltc1q...)</li>
<li>You receive an extended private key (zprv/...) that can be imported into external wallets</li>
<li>• Best for: fresh installs, low storage, quick setup, mobile-friendly</li>
</ul>
</div>
<div>
<p class="text-xs font-medium text-gray-900 dark:text-white mb-1">While in Light Wallet mode:</p>
<p class="text-xs font-medium text-gray-900 dark:text-white mb-1">Full Node Mode (RPC):</p>
<ul class="text-xs text-gray-700 dark:text-gray-200 space-y-0.5 ml-3">
<li>Light wallet generates NEW addresses (BIP84 format: bc1q.../ltc1q...)</li>
<li>Any funds you RECEIVE go to these new addresses</li>
<li>Your full node doesn't know about these addresses</li>
<li>Maximum privacy - no external servers, your node validates everything</li>
<li>More wallet features: coin control, RBF, CPFP, raw transactions</li>
<li>Supports legacy address types and coin-specific features (e.g. MWEB for LTC)</li>
<li>• Best for: existing node users, power users, maximum control</li>
</ul>
</div>
<div>
<p class="text-xs font-medium text-gray-900 dark:text-white mb-1">To Full Node:</p>
<p class="text-xs font-medium text-gray-900 dark:text-white mb-1">When switching modes:</p>
<ul class="text-xs text-gray-700 dark:text-gray-200 space-y-0.5 ml-3">
<li>Full node can't see funds on light wallet addresses</li>
<li>• These funds must be SENT back to your node wallet (real transaction, network fee applies based on current rate)</li>
<li>Enable "Auto-transfer" in Fund Transfer section to do this automatically on unlock</li>
<li>To Light: Save your BIP84 key shown during switch (for external wallet import)</li>
<li>• To Full Node: Funds on light wallet addresses must be transferred (network fee applies)</li>
<li>Both modes share the same seed - switching is safe, just save keys when shown</li>
</ul>
</div>
@@ -259,13 +261,12 @@
</div>
<div id="fund-transfer-section-{{ c.name }}" class="mb-6 hidden">
{% if c.lite_wallet_balance %}
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4">
Fund Transfer
Pending Balance
</h4>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
{% if c.lite_wallet_balance %}
<div class="mb-4 p-3 bg-orange-50 dark:bg-orange-900/30 border border-orange-200 dark:border-orange-700 rounded-lg">
<div class="p-3 bg-orange-50 dark:bg-orange-900/30 border border-orange-200 dark:border-orange-700 rounded-lg">
<p class="text-sm font-medium text-orange-800 dark:text-orange-200 mb-2">
<svg class="inline w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>
Light Wallet Balance Detected
@@ -292,24 +293,24 @@
{% endif %}
</div>
</div>
{% endif %}
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="auto_transfer_{{ c.name }}" value="true" {% if c.auto_transfer_on_mode_switch != false %}checked{% endif %} class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<span class="ml-2 text-sm font-medium text-red-600 dark:text-red-400">Auto-transfer funds when switching to Full Node</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2 ml-6">Funds in light wallet addresses will be swept to your RPC wallet after switching. Network fee applies based on current rate.</p>
</div>
{% endif %}
{% if general_settings.debug %}
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
{% if general_settings.debug %}
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4 {% if c.lite_wallet_balance %}mt-6{% endif %}">
Advanced
</h4>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address Gap Limit</label>
<div class="flex items-center">
<input type="number" name="gap_limit_{{ c.name }}" value="{{ c.address_gap_limit }}" min="5" max="100" class="hover:border-blue-500 bg-gray-50 text-gray-900 appearance-none dark:bg-gray-700 dark:text-white border border-gray-300 dark:border-gray-600 dark:placeholder-gray-400 text-sm rounded-lg outline-none focus:ring-blue-500 focus:border-blue-500 block w-24 p-2.5 focus:ring-1 focus:outline-none" placeholder="20">
<input type="number" name="gap_limit_{{ c.name }}" value="{{ c.address_gap_limit }}" min="5" max="100" class="hover:border-blue-500 bg-gray-50 text-gray-900 appearance-none dark:bg-gray-700 dark:text-white border border-gray-300 dark:border-gray-600 dark:placeholder-gray-400 text-sm rounded-lg outline-none focus:ring-blue-500 focus:border-blue-500 block w-24 p-2.5 focus:ring-1 focus:outline-none" placeholder="50">
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(5-100)</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Number of consecutive unfunded addresses to scan. Increase if you generated many unused addresses.</p>
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{% endif %}
@@ -864,7 +865,7 @@
Switch Mode
</button>
<button type="button" id="walletModeCancel"
class="px-4 py-2.5 bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-700 font-medium text-sm text-gray-700 dark:text-white rounded-md focus:ring-0 focus:outline-none">
class="px-4 py-2.5 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-700 font-medium text-sm text-gray-800 dark:text-white rounded-md border border-gray-300 dark:border-gray-500 focus:ring-0 focus:outline-none">
Cancel
</button>
</div>
@@ -875,4 +876,4 @@
<script src="/static/js/pages/settings-page.js"></script>
{% include 'footer.html' %}
{% include 'footer.html' %}
+67 -3
View File
@@ -35,6 +35,8 @@
</section>
{% endif %}
{% if w.havedata %}
{% if w.error %}
<section class="py-4 px-6" id="messages_error" role="alert">
@@ -183,8 +185,20 @@
{% endif %}
</td>
</tr>
{% elif w.cid == '13' %} {# FIRO #}
<tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600">
<td class="py-3 px-6 bold"> <span class="inline-flex align-middle items-center justify-center w-9 h-10 bg-white-50 rounded"> <img class="h-7" src="/static/images/coins/{{ w.name }}.png" alt="{{ w.name }} Spark"> </span>Spark Balance: </td>
<td class="py-3 px-6 bold">
<span class="coinname-value" data-coinname="{{ w.name }}">{{ w.spark_balance }} {{ w.ticker }}</span>
(<span class="usd-value"></span>)
{% if w.spark_pending %}
<span class="inline-block py-1 px-2 rounded-full bg-green-100 text-green-500 dark:bg-gray-500 dark:text-green-500">Pending: +{{ w.spark_pending }} {{ w.ticker }} </span>
{% endif %}
</td>
</tr>
{% endif %}
{# / LTC #}
{# / FIRO #}
{% if w.locked_utxos %}
<tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600">
<td class="py-3 px-6 bold">Locked Outputs:</td>
@@ -230,7 +244,11 @@
<td class="py-3 px-6 bold">Synced:</td>
<td class="py-3 px-6">
{% if is_electrum_mode %}
<span class="text-green-600 dark:text-green-400">Electrum Wallet Synced</span>
{% if w.electrum_synced %}
<span class="text-green-600 dark:text-green-400">Electrum Wallet Synced ({{ w.electrum_height }})</span>
{% else %}
<span class="text-yellow-600 dark:text-yellow-400">Waiting for Electrum Server...</span>
{% endif %}
{% else %}
{{ w.synced }}
{% endif %}
@@ -394,8 +412,8 @@
</div>
</div>
</div>
{% if w.cid in '1, 3, 6, 9' %}
{# PART | LTC | XMR | WOW | #}
{% if w.cid in '1, 3, 6, 9, 13' %}
{# PART | LTC | XMR | WOW | FIRO #}
<div class="w-full md:w-1/2 p-3 flex justify-center items-center">
<div class="h-full">
<div class="flex flex-wrap -m-3">
@@ -444,6 +462,22 @@
</div>
{% endif %}
{# / LTC #}
{% elif w.cid == '13' %}
{# FIRO #}
<div id="qrcode-spark" class="qrcode" data-qrcode data-address="{{ w.spark_address }}"> </div>
</div>
</div>
<div class="font-normal bold text-gray-500 text-center dark:text-white mb-5">Spark Address: </div>
<div class="text-center relative">
<div class="input-like-container hover:border-blue-500 bg-gray-50 text-gray-900 appearance-none pr-10 dark:bg-gray-500 dark:text-white border border-gray-300 dark:border-gray-400 dark:text-gray-50 dark:placeholder-gray-400 text-lg lg:text-sm rounded-lg outline-none focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 focus:ring-0" id="stealth_address">{{ w.spark_address }}</div>
<span class="absolute inset-y-0 right-0 flex items-center pr-3 cursor-pointer" id="copyIcon"></span>
</div>
<div class="opacity-100 text-gray-500 dark:text-gray-100 flex justify-center items-center">
<div class="py-3 px-6 bold mt-5">
<button type="submit" class="flex justify-center py-2 px-4 bg-blue-500 hover:bg-blue-600 font-medium text-sm text-white border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none" name="newsparkaddr_{{ w.cid }}" value="New Spark Address"> {{ circular_arrows_svg }} New Spark Address </button>
</div>
</div>
{# / FIRO #}
{% endif %}
</div>
</div>
@@ -507,6 +541,15 @@
{% endif %}
</td>
</tr>
{% elif w.cid == '13' %}
{# FIRO #}
<tr class="opacity-100 text-gray-500 dark:text-gray-100">
<td class="py-4 pl-6 bold w-1/4"> <span class="inline-flex align-middle items-center justify-center w-9 h-10 bg-white-50 rounded"> <img class="h-7" src="/static/images/coins/{{ w.name }}.png" alt="{{ w.name }}"> </span>Spark Balance: </td>
<td class="py-3 px-6">
<span class="coinname-value" data-coinname="{{ w.name }}">{{ w.spark_balance }} {{ w.ticker }}</span>
(<span class="usd-value"></span>)
</td>
</tr>
{% elif w.cid == '1' %}
{# PART #}
<tr class="opacity-100 text-gray-500 dark:text-gray-100">
@@ -597,6 +640,14 @@
<button type="button" class="ml-2 py-1 px-2 bg-blue-500 text-white text-lg lg:text-sm rounded-md focus:outline-none" onclick="setAmount(1, '{{ w.balance }}', {{ w.cid }}, '{{ w.mweb_balance }}')">100%</button>
{# / LTC #}
{% elif w.cid == '13' %}
{# FIRO #}
<button type="button" class="hidden md:block py-1 px-2 bg-blue-500 text-white text-lg lg:text-sm rounded-md focus:outline-none" onclick="setAmount(0.25, '{{ w.balance }}', {{ w.cid }}, '{{ w.spark_balance }}')">25%</button>
<button type="button" class="hidden md:block ml-2 py-1 px-2 bg-blue-500 text-white text-lg lg:text-sm rounded-md focus:outline-none" onclick="setAmount(0.5, '{{ w.balance }}', {{ w.cid }}, '{{ w.spark_balance }}')">50%</button>
<button type="button" class="ml-2 py-1 px-2 bg-blue-500 text-white text-lg lg:text-sm rounded-md focus:outline-none" onclick="setAmount(1, '{{ w.balance }}', {{ w.cid }}, '{{ w.spark_balance }}')">100%</button>
{# / FIRO #}
{% else %}
<button type="button" class="hidden md:block py-1 px-2 bg-blue-500 text-white text-lg lg:text-sm rounded-md focus:outline-none" onclick="setAmount(0.25, '{{ w.balance }}', {{ w.cid }})">25%</button>
<button type="button" class="hidden md:block ml-2 py-1 px-2 bg-blue-500 text-white text-lg lg:text-sm rounded-md focus:outline-none" onclick="setAmount(0.5, '{{ w.balance }}', {{ w.cid }})">50%</button>
@@ -663,8 +714,21 @@
</div>
</td>
</tr>
{% elif w.cid == '13' %} {# FIRO #}
<tr class="opacity-100 text-gray-500 dark:text-gray-100">
<td class="py-3 px-6 bold">Type From:</td>
<td class="py-3 px-6">
<div class="w-full md:flex-1">
<div class="relative"> {{ select_box_arrow_svg }} <select id="withdraw_type" class="{{ select_box_class }}" name="withdraw_type_from_{{ w.cid }}">
<option value="spark" {% if w.wd_type_from=='spark' %} selected{% endif %}>Spark</option>
<option value="plain" {% if w.wd_type_from=='plain' %} selected{% endif %}>Plain</option>
</select> </div>
</div>
</td>
</tr>
{% endif %}
{# / LTC #}
{# / FIRO #}
{% if w.cid not in '6,9' %} {# Not XMR WOW #}
<tr class="opacity-100 text-gray-500 dark:text-gray-100">
<td class="py-3 px-6 bold">Fee Rate:</td>
+30 -2
View File
@@ -148,6 +148,28 @@
{% endif %}
{% endif %}
{# / LTC #}
{% if w.cid == '13' %} {# FIRO #}
<div class="flex mb-2 justify-between items-center">
<h4 class="text-xs font-medium dark:text-white">Spark Balance:</h4>
<span class="bold inline-block py-1 px-2 rounded-full bg-blue-100 text-xs text-black-500 dark:bg-gray-500 dark:text-gray-200 coinname-value" data-coinname="{{ w.name }}">{{ w.spark_balance }} {{ w.ticker }}</span>
</div>
<div class="flex mb-2 justify-between items-center">
<h4 class="text-xs font-medium dark:text-white">Spark USD value:</h4>
<div class="bold inline-block py-1 px-2 rounded-full bg-blue-100 text-xs text-black-500 dark:bg-gray-500 dark:text-gray-200 usd-value"></div>
</div>
{% if w.spark_pending %}
<div class="flex mb-2 justify-between items-center">
<h4 class="text-xs font-bold text-green-500 dark:text-green-500">Spark Pending:</h4>
<span class="bold inline-block py-1 px-2 rounded-full bg-green-100 text-xs text-green-500 dark:bg-gray-500 dark:text-green-500 coinname-value" data-coinname="{{ w.name }}">
+{{ w.spark_pending }} {{ w.ticker }}</span>
</div>
<div class="flex mb-2 justify-between items-center">
<h4 class="text-xs font-bold text-green-500 dark:text-green-500">Spark Pending USD value:</h4>
<div class="bold inline-block py-1 px-2 rounded-full bg-green-100 text-xs text-green-500 dark:bg-gray-500 dark:text-green-500 usd-value"></div>
</div>
{% endif %}
{% endif %}
{# / FIRO #}
<hr class="border-t border-gray-100 dark:border-gray-500 my-5">
<div class="flex mb-2 justify-between items-center">
<h4 class="text-xs font-medium dark:text-white">Blocks:</h4>
@@ -193,10 +215,16 @@
<div class="bg-blue-600 dark:bg-blue-400 h-1 rounded-full" style="width: {{ w.scan_status.progress }}%"></div>
</div>
</div>
{% else %}
{% elif w.electrum_synced %}
<div class="bg-green-50 dark:bg-gray-500 p-2 rounded">
<div class="flex items-center text-xs text-green-600 dark:text-green-400">
Electrum Wallet Synced
Electrum Wallet Synced ({{ w.electrum_height }})
</div>
</div>
{% else %}
<div class="bg-yellow-50 dark:bg-gray-500 p-2 rounded">
<div class="flex items-center text-xs text-yellow-600 dark:text-yellow-400">
Waiting for Electrum Server...
</div>
</div>
{% endif %}
+2 -2
View File
@@ -189,7 +189,7 @@ def page_settings(self, url_split, post_string):
)
data["auto_transfer_now"] = transfer_value == "true"
gap_limit_str = get_data_entry_or(
form_data, "gap_limit_" + name, "20"
form_data, "gap_limit_" + name, "50"
).strip()
try:
gap_limit = int(gap_limit_str)
@@ -295,7 +295,7 @@ def page_settings(self, url_split, post_string):
"supports_electrum": name in electrum_supported_coins,
"clearnet_servers_text": clearnet_text,
"onion_servers_text": onion_text,
"address_gap_limit": c.get("address_gap_limit", 20),
"address_gap_limit": c.get("address_gap_limit", 50),
}
)
if name in ("monero", "wownero"):
+24 -5
View File
@@ -90,6 +90,10 @@ def format_wallet_data(swap_client, ci, w):
wf["mweb_address"] = w.get("mweb_address", "?")
wf["mweb_balance"] = w.get("mweb_balance", "?")
wf["mweb_pending"] = w.get("mweb_pending", "?")
elif ci.coin_type() == Coins.FIRO:
wf["spark_address"] = w.get("spark_address", "?")
wf["spark_balance"] = w.get("spark_balance", "?")
wf["spark_pending"] = w.get("spark_pending", "?")
if hasattr(ci, "getScanStatus"):
wf["scan_status"] = ci.getScanStatus()
@@ -114,6 +118,13 @@ def format_wallet_data(swap_client, ci, w):
except Exception:
wf["electrum_connected"] = False
wf["electrum_status"] = "error"
try:
sync_status = backend.getSyncStatus()
wf["electrum_synced"] = sync_status.get("synced", False)
wf["electrum_height"] = sync_status.get("height", 0)
except Exception:
wf["electrum_synced"] = False
wf["electrum_height"] = 0
checkAddressesOwned(swap_client, ci, wf)
return wf
@@ -264,6 +275,8 @@ def page_wallet(self, url_split, post_string):
force_refresh = True
elif have_data_entry(form_data, "newmwebaddr_" + cid):
swap_client.cacheNewStealthAddressForCoin(coin_id)
elif have_data_entry(form_data, "newsparkaddr_" + cid):
swap_client.cacheNewStealthAddressForCoin(coin_id)
elif have_data_entry(form_data, "reseed_" + cid):
try:
swap_client.reseedWallet(coin_id)
@@ -325,7 +338,7 @@ def page_wallet(self, url_split, post_string):
page_data["wd_type_to_" + cid] = type_to
except Exception as e: # noqa: F841
err_messages.append("Missing type")
elif coin_id == Coins.LTC:
elif coin_id in (Coins.LTC, Coins.FIRO):
try:
type_from = form_data[bytes("withdraw_type_from_" + cid, "utf-8")][
0
@@ -354,9 +367,9 @@ def page_wallet(self, url_split, post_string):
value, ticker, type_from, type_to, address, txid
)
)
elif coin_id == Coins.LTC:
txid = swap_client.withdrawLTC(
type_from, value, address, subfee
elif coin_id in (Coins.LTC, Coins.FIRO):
txid = swap_client.withdrawCoinExtended(
coin_id, type_from, value, address, subfee
)
messages.append(
"Withdrew {} {} (from {}) to address {}<br/>In txid: {}".format(
@@ -429,8 +442,12 @@ def page_wallet(self, url_split, post_string):
if swap_client.debug is True:
swap_client.log.error(traceback.format_exc())
is_electrum_mode = (
swap_client.coin_clients.get(coin_id, {}).get("connection_type") == "electrum"
)
swap_client.updateWalletsInfo(
force_refresh, only_coin=coin_id, wait_for_complete=True
force_refresh, only_coin=coin_id, wait_for_complete=not is_electrum_mode
)
wallets = swap_client.getCachedWalletsInfo({"coin_id": coin_id})
wallet_data = {}
@@ -469,6 +486,8 @@ def page_wallet(self, url_split, post_string):
wallet_data["main_address"] = w.get("main_address", "Refresh necessary")
elif k == Coins.LTC:
wallet_data["mweb_address"] = w.get("mweb_address", "Refresh necessary")
elif k == Coins.FIRO:
wallet_data["spark_address"] = w.get("spark_address", "Refresh necessary")
if "wd_type_from_" + cid in page_data:
wallet_data["wd_type_from"] = page_data["wd_type_from_" + cid]
+75 -9
View File
@@ -197,7 +197,11 @@ class ElectrumBackend(WalletBackend):
self._cached_height_time = 0
self._height_cache_ttl = 5
self._max_batch_size = 10
self._cached_fee = {}
self._cached_fee_time = {}
self._fee_cache_ttl = 300
self._max_batch_size = 5
self._background_mode = False
def setBackgroundMode(self, enabled: bool):
@@ -206,13 +210,20 @@ class ElectrumBackend(WalletBackend):
def _call(self, method: str, params: list = None, timeout: int = 10):
if self._background_mode and hasattr(self._server, "call_background"):
return self._server.call_background(method, params, timeout)
if hasattr(self._server, "call_user"):
return self._server.call_user(method, params, timeout)
return self._server.call(method, params, timeout)
def _call_batch(self, calls: list, timeout: int = 15):
if self._background_mode and hasattr(self._server, "call_batch_background"):
return self._server.call_batch_background(calls, timeout)
if hasattr(self._server, "call_batch_user"):
return self._server.call_batch_user(calls, timeout)
return self._server.call_batch(calls, timeout)
def _is_server_stopping(self) -> bool:
return getattr(self._server, "_stopping", False)
def _split_batch_call(
self, scripthashes: list, method: str, batch_size: int = None
) -> list:
@@ -221,19 +232,30 @@ class ElectrumBackend(WalletBackend):
all_results = []
for i in range(0, len(scripthashes), batch_size):
if self._is_server_stopping():
self._log.debug("_split_batch_call: server stopping, aborting")
break
chunk = scripthashes[i : i + batch_size]
try:
calls = [(method, [sh]) for sh in chunk]
results = self._call_batch(calls)
all_results.extend(results)
except Exception as e:
self._log.debug(f"Batch chunk failed ({len(chunk)} items): {e}")
except Exception:
if self._is_server_stopping():
self._log.debug(
"_split_batch_call: server stopping after batch failure, aborting"
)
break
for sh in chunk:
if self._is_server_stopping():
self._log.debug(
"_split_batch_call: server stopping during fallback, aborting"
)
break
try:
result = self._call(method, [sh])
all_results.append(result)
except Exception as e2:
self._log.debug(f"Individual call failed for {sh[:8]}...: {e2}")
except Exception:
all_results.append(None)
return all_results
@@ -298,8 +320,10 @@ class ElectrumBackend(WalletBackend):
if not addr_list:
return result
batch_size = 10
batch_size = self._max_batch_size
for batch_start in range(0, len(addr_list), batch_size):
if self._is_server_stopping():
break
batch = addr_list[batch_start : batch_start + batch_size]
addr_to_scripthash = {}
@@ -332,6 +356,8 @@ class ElectrumBackend(WalletBackend):
batch_success = True
break
except Exception as e:
if self._is_server_stopping():
break
if attempt == 0:
self._log.debug(
f"Batch detailed balance query failed, reconnecting: {e}"
@@ -348,6 +374,8 @@ class ElectrumBackend(WalletBackend):
if not batch_success:
for addr, scripthash in addr_to_scripthash.items():
if self._is_server_stopping():
break
try:
balance = self._call(
"blockchain.scripthash.get_balance", [scripthash]
@@ -569,13 +597,22 @@ class ElectrumBackend(WalletBackend):
return self._cached_height if self._cached_height > 0 else 0
def estimateFee(self, blocks: int = 6) -> int:
now = time.time()
cache_key = blocks
if cache_key in self._cached_fee:
if (now - self._cached_fee_time.get(cache_key, 0)) < self._fee_cache_ttl:
return self._cached_fee[cache_key]
try:
fee = self._call("blockchain.estimatefee", [blocks])
if fee and fee > 0:
return int(fee * 1e8 / 1000)
return 1
result = int(fee * 1e8 / 1000)
self._cached_fee[cache_key] = result
self._cached_fee_time[cache_key] = now
return result
return self._cached_fee.get(cache_key, 1)
except Exception:
return 1
return self._cached_fee.get(cache_key, 1)
def isConnected(self) -> bool:
try:
@@ -616,6 +653,11 @@ class ElectrumBackend(WalletBackend):
status["version"] = self.getServerVersion()
return status
def recentlyReconnected(self, grace_seconds: int = 30) -> bool:
if hasattr(self._server, "recently_reconnected"):
return self._server.recently_reconnected(grace_seconds)
return False
def getAddressHistory(self, address: str) -> List[dict]:
if self._isUnsupportedAddress(address):
return []
@@ -751,5 +793,29 @@ class ElectrumBackend(WalletBackend):
self._log.debug(f"Failed to subscribe to {address}: {e}")
return None
def getSyncStatus(self) -> dict:
import time
height = 0
height_time = 0
if hasattr(self._server, "get_subscribed_height"):
height = self._server.get_subscribed_height()
height_time = getattr(self._server, "_subscribed_height_time", 0)
if self._cached_height > 0:
if self._cached_height > height:
height = self._cached_height
if self._cached_height_time > height_time:
height_time = self._cached_height_time
now = time.time()
stale_threshold = 300
is_synced = height > 0 and (now - height_time) < stale_threshold
return {
"height": height,
"synced": is_synced,
"last_update": height_time,
}
def getServer(self):
return self._server
+7 -1
View File
@@ -37,7 +37,7 @@ class WalletManager:
Coins.LTC: {"mainnet": "ltc", "testnet": "tltc", "regtest": "rltc"},
}
GAP_LIMIT = 20
GAP_LIMIT = 50
def __init__(self, swap_client, log):
self._gap_limits: Dict[Coins, int] = {}
@@ -51,6 +51,12 @@ class WalletManager:
self._migration_in_progress: set = set()
self._balance_sync_lock = threading.Lock()
def getGapLimit(self, coin_type: Coins) -> int:
return self._gap_limits.get(coin_type, self.GAP_LIMIT)
def setGapLimit(self, coin_type: Coins, gap_limit: int) -> None:
self._gap_limits[coin_type] = gap_limit
def initialize(self, coin_type: Coins, root_key) -> None:
if coin_type not in self.SUPPORTED_COINS:
raise ValueError(f"Coin {coin_type} not supported by WalletManager")
+5 -1
View File
@@ -1489,7 +1489,11 @@ class Test(BaseTest):
v = ci.getNewRandomKey()
s = ci.getNewRandomKey()
S = ci.getPubkey(s)
lock_tx_b_txid = ci.publishBLockTx(v, S, amount, fee_rate)
result = ci.publishBLockTx(v, S, amount, fee_rate)
if isinstance(result, tuple):
lock_tx_b_txid, lock_tx_b_vout = result
else:
lock_tx_b_txid = result
test_delay_event.wait(1)
addr_out = ci.getNewAddress(True)
+5 -1
View File
@@ -412,7 +412,11 @@ class Test(TestFunctions):
v = ci.getNewRandomKey()
s = ci.getNewRandomKey()
S = ci.getPubkey(s)
lock_tx_b_txid = ci.publishBLockTx(v, S, amount, fee_rate)
result = ci.publishBLockTx(v, S, amount, fee_rate)
if isinstance(result, tuple):
lock_tx_b_txid, lock_tx_b_vout = result
else:
lock_tx_b_txid = result
test_delay_event.wait(1)
addr_out = ci.getNewAddress(False)
+5 -1
View File
@@ -1579,7 +1579,11 @@ class BasicSwapTest(TestFunctions):
v = ci.getNewRandomKey()
s = ci.getNewRandomKey()
S = ci.getPubkey(s)
lock_tx_b_txid = ci.publishBLockTx(v, S, amount, self.test_fee_rate)
result = ci.publishBLockTx(v, S, amount, self.test_fee_rate)
if isinstance(result, tuple):
lock_tx_b_txid, lock_tx_b_vout = result
else:
lock_tx_b_txid = result
addr_out = ci.getNewAddress(True)
lock_tx_b_spend_txid = ci.spendBLockTx(
+15 -3
View File
@@ -1218,7 +1218,11 @@ class Test(BaseTest):
v = ci.getNewRandomKey()
s = ci.getNewRandomKey()
S = ci.getPubkey(s)
lock_tx_b_txid = ci.publishBLockTx(v, S, amount, fee_rate)
result = ci.publishBLockTx(v, S, amount, fee_rate)
if isinstance(result, tuple):
lock_tx_b_txid, lock_tx_b_vout = result
else:
lock_tx_b_txid = result
addr_out = ci.getNewAddress(True)
lock_tx_b_spend_txid = ci.spendBLockTx(
@@ -1247,7 +1251,11 @@ class Test(BaseTest):
v = ci.getNewRandomKey()
s = ci.getNewRandomKey()
S = ci.getPubkey(s)
lock_tx_b_txid = ci.publishBLockTx(v, S, amount, fee_rate)
result = ci.publishBLockTx(v, S, amount, fee_rate)
if isinstance(result, tuple):
lock_tx_b_txid, lock_tx_b_vout = result
else:
lock_tx_b_txid = result
addr_out = ci.getNewAddress(True)
for i in range(20):
@@ -2306,7 +2314,11 @@ class Test(BaseTest):
v = ci.getNewRandomKey()
s = ci.getNewRandomKey()
S = ci.getPubkey(s)
lock_tx_b_txid = ci.publishBLockTx(v, S, amount, fee_rate)
result = ci.publishBLockTx(v, S, amount, fee_rate)
if isinstance(result, tuple):
lock_tx_b_txid, lock_tx_b_vout = result
else:
lock_tx_b_txid = result
addr_out = ci.getNewStealthAddress()
lock_tx_b_spend_txid = None