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

View File

@@ -229,23 +229,35 @@ def checkAndNotifyBalanceChange(
cc["cached_balance"] = current_balance cc["cached_balance"] = current_balance
cc["cached_total_balance"] = current_total_balance cc["cached_total_balance"] = current_total_balance
cc["cached_unconfirmed"] = current_unconfirmed cc["cached_unconfirmed"] = current_unconfirmed
swap_client.log.debug(
f"{ci.ticker()} balance updated (trigger: {trigger_source})" suppress = False
) if cached_balance is None or cached_total_balance is None:
balance_event = { suppress = True
"event": "coin_balance_updated", elif hasattr(ci, "getBackend") and ci.useBackend():
"coin": ci.ticker(), backend = ci.getBackend()
"height": new_height, if backend and hasattr(backend, "recentlyReconnected"):
"trigger": trigger_source, if backend.recentlyReconnected(grace_seconds=30):
} suppress = True
swap_client.ws_server.send_message_to_all(json.dumps(balance_event))
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: except Exception as e:
swap_client.log.debug( swap_client.log.debug(
f"checkAndNotifyBalanceChange {ci.ticker()}: balance check failed: {e}" 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): def threadPollXMRChainState(swap_client, coin_type):
@@ -458,6 +470,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
self._updating_wallets_info = {} self._updating_wallets_info = {}
self._last_updated_wallets_info = 0 self._last_updated_wallets_info = 0
self._synced_addresses_from_full_node = set() self._synced_addresses_from_full_node = set()
self._cached_electrum_legacy_funds = {}
self.check_updates_seconds = self.get_int_setting( self.check_updates_seconds = self.get_int_setting(
"check_updates_seconds", 24 * 60 * 60, 60 * 60, 7 * 24 * 60 * 60 "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( self.thread_pool = concurrent.futures.ThreadPoolExecutor(
max_workers=4, thread_name_prefix="bsp" max_workers=4, thread_name_prefix="bsp"
) )
self._electrum_spend_check_futures = {}
# Encode key to match network # Encode key to match network
wif_prefix = chainparams[Coins.PART][self.chain]["key_prefix"] wif_prefix = chainparams[Coins.PART][self.chain]["key_prefix"]
@@ -736,24 +750,32 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
self.delay_event.set() self.delay_event.set()
self.chainstate_delay_event.set() self.chainstate_delay_event.set()
if self._network: for coin_type, cc in self.coin_clients.items():
self._network.stopNetwork() interface = cc.get("interface")
self._network = None if (
interface
for coin_type, interface in self.coin_interfaces.items(): and hasattr(interface, "_backend")
if hasattr(interface, "_backend") and interface._backend is not None: and interface._backend is not None
):
try: try:
if hasattr(interface._backend, "_server"): 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}") self.log.debug(f"Disconnected electrum backend for {coin_type}")
except Exception as e: except Exception as e:
self.log.debug(f"Error disconnecting electrum backend: {e}") self.log.debug(f"Error disconnecting electrum backend: {e}")
if self._network:
self._network.stopNetwork()
self._network = None
self.log.info("Stopping threads.") self.log.info("Stopping threads.")
for t in self.threads: for t in self.threads:
if hasattr(t, "stop") and callable(t.stop): if hasattr(t, "stop") and callable(t.stop):
t.stop() t.stop()
t.join() t.join(timeout=15)
if sys.version_info[1] >= 9: if sys.version_info[1] >= 9:
self.thread_pool.shutdown(cancel_futures=True) self.thread_pool.shutdown(cancel_futures=True)
@@ -1927,15 +1949,29 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
cc["cached_total_balance"] = current_total cc["cached_total_balance"] = current_total
cc["cached_unconfirmed"] = current_total - current_balance cc["cached_unconfirmed"] = current_total - current_balance
balance_event = { suppress = False
"event": "coin_balance_updated", if hasattr(ci, "getBackend") and ci.useBackend():
"coin": ci.ticker(), backend = ci.getBackend()
"height": cc.get("chain_height", 0), if backend and hasattr(backend, "recentlyReconnected"):
"trigger": "electrum_notification", if backend.recentlyReconnected(grace_seconds=30):
"address": address[:20] + "..." if address else None, suppress = True
}
self.ws_server.send_message_to_all(json.dumps(balance_event)) if suppress:
self.log.debug(f"Electrum notification: {ci.ticker()} balance updated") 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: except Exception as e:
self.log.debug(f"Error handling electrum notification: {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" f"will sync keypool and trigger rescan in full node"
) )
return { return {
"empty": True, "empty": False,
"reason": "has_balance",
"has_balance": True, "has_balance": True,
"balance_sats": balance_sats, "balance_sats": balance_sats,
"message": ( "message": (
@@ -2495,9 +2532,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
else: else:
self.log.warning(f"Sweep skipped for {coin_name}: {reason}") self.log.warning(f"Sweep skipped for {coin_name}: {reason}")
elif result.get("txid"): elif result.get("txid"):
self.log.info( pass
f"Sweep completed: {result.get('amount', 0) / 1e8:.8f} {coin_name} swept to RPC wallet"
)
elif result.get("error"): elif result.get("error"):
self.log.warning( self.log.warning(
f"Sweep failed for {coin_name}: {result.get('error')}" f"Sweep failed for {coin_name}: {result.get('error')}"
@@ -2745,6 +2780,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
{ {
"coin_type": int(coin_type), "coin_type": int(coin_type),
"coin_name": coin_name, "coin_name": coin_name,
"ticker": chainparams[coin_type]["ticker"],
"amount": send_amount / 1e8, "amount": send_amount / 1e8,
"fee": fee / 1e8, "fee": fee / 1e8,
"txid": txid_hex, "txid": txid_hex,
@@ -2823,15 +2859,27 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
except Exception as e: except Exception as e:
return {"error": f"Failed to list UTXOs: {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 = [] legacy_utxos = []
total_legacy_sats = 0 total_legacy_sats = 0
for u in unspent: 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 continue
addr = u["address"] 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) legacy_utxos.append(u)
total_legacy_sats += ci.make_int(u.get("amount", 0)) 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})", "reason": f"Legacy balance ({total_legacy_sats}) too low for fee ({estimated_fee_sats})",
} }
try: new_address = None
new_address = ci.rpc_wallet("getnewaddress", ["consolidate", "bech32"]) if wm:
except Exception as e: try:
return {"error": f"Failed to get new address: {e}"} 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_sats = total_legacy_sats - estimated_fee_sats
send_amount_btc = ci.format_amount(send_amount_sats) send_amount_btc = ci.format_amount(send_amount_sats)
@@ -2994,6 +3058,12 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
return None return None
def getElectrumLegacyFundsInfo(self, coin_type: Coins) -> dict: 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: try:
cc = self.coin_clients.get(coin_type) cc = self.coin_clients.get(coin_type)
if not cc or cc.get("connection_type") != "electrum": if not cc or cc.get("connection_type") != "electrum":
@@ -3027,7 +3097,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
} }
return {"has_legacy_funds": False} return {"has_legacy_funds": False}
except Exception as e: except Exception as e:
self.log.debug(f"getElectrumLegacyFundsInfo error: {e}") self.log.debug(f"_computeElectrumLegacyFundsInfo error: {e}")
return {"has_legacy_funds": False} return {"has_legacy_funds": False}
def _tryGetFullNodeAddresses(self, coin_type: Coins) -> list: def _tryGetFullNodeAddresses(self, coin_type: Coins) -> list:
@@ -4528,10 +4598,12 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
self.log.info_s(f"In txn: {txid}") self.log.info_s(f"In txn: {txid}")
return txid return txid
def withdrawLTC(self, type_from, value, addr_to, subfee: bool) -> str: def withdrawCoinExtended(
ci = self.ci(Coins.LTC) self, coin_type, type_from, value, addr_to, subfee: bool
) -> str:
ci = self.ci(coin_type)
self.log.info( self.log.info(
"withdrawLTC{}".format( "withdrawCoinExtended{}".format(
"" ""
if self.log.safe_logs if self.log.safe_logs
else " {} {} to {} {}".format( else " {} {} to {} {}".format(
@@ -7454,6 +7526,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
dest_address, dest_address,
bid.amount_to, bid.amount_to,
bid.chain_b_height_start, bid.chain_b_height_start,
find_index=True,
vout=bid.xmr_b_lock_tx.vout, vout=bid.xmr_b_lock_tx.vout,
) )
else: else:
@@ -7498,7 +7571,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
bid_id=bid.bid_id, bid_id=bid.bid_id,
tx_type=TxTypes.XMR_SWAP_B_LOCK, tx_type=TxTypes.XMR_SWAP_B_LOCK,
txid=xmr_swap.b_lock_tx_id, txid=xmr_swap.b_lock_tx_id,
vout=0, vout=found_tx.get("index", 0),
) )
if bid.xmr_b_lock_tx.txid != found_txid: if bid.xmr_b_lock_tx.txid != found_txid:
self.log.debug( self.log.debug(
@@ -9328,6 +9401,95 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
c["last_height_checked"] = chain_blocks 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: def expireMessageRoutes(self) -> None:
if self._is_locked is True: if self._is_locked is True:
self.log.debug("Not expiring message routes while system is locked") self.log.debug("Not expiring message routes while system is locked")
@@ -9526,11 +9688,12 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
try: try:
cursor = self.openDB() 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() rows = cursor.execute(query, {"now": now}).fetchall()
retry_action_ids = []
for row in rows: for row in rows:
action_type, linked_id = row action_id, action_type, linked_id = row
accepting_bid: bool = False accepting_bid: bool = False
try: try:
if action_type == ActionTypes.ACCEPT_BID: if action_type == ActionTypes.ACCEPT_BID:
@@ -9562,6 +9725,11 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
self.acceptADSReverseBid(linked_id, cursor) self.acceptADSReverseBid(linked_id, cursor)
else: else:
self.log.warning(f"Unknown event type: {action_type}") 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: except Exception as ex:
err_msg = f"checkQueuedActions failed: {ex}" err_msg = f"checkQueuedActions failed: {ex}"
self.logException(err_msg) self.logException(err_msg)
@@ -9594,10 +9762,23 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
bid.setState(BidStates.BID_ERROR) bid.setState(BidStates.BID_ERROR)
self.saveBidInSession(bid_id, bid, cursor) self.saveBidInSession(bid_id, bid, cursor)
query: str = "DELETE FROM actions WHERE trigger_at <= :now" if retry_action_ids:
if self.debug: placeholders = ",".join(
query = "UPDATE actions SET active_ind = 2 WHERE trigger_at <= :now" f":retry_{i}" for i in range(len(retry_action_ids))
cursor.execute(query, {"now": now}) )
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: except Exception as ex:
self.handleSessionErrors(ex, cursor, "checkQueuedActions") self.handleSessionErrors(ex, cursor, "checkQueuedActions")
@@ -11349,13 +11530,18 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
) )
try: try:
b_lock_tx_id = ci_to.publishBLockTx( b_lock_vout = 0
result = ci_to.publishBLockTx(
xmr_swap.vkbv, xmr_swap.vkbv,
xmr_swap.pkbs, xmr_swap.pkbs,
bid.amount_to, bid.amount_to,
b_fee_rate, b_fee_rate,
unlock_time=unlock_time, 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: if bid.debug_ind == DebugTypes.B_LOCK_TX_MISSED_SEND:
self.log.debug( 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)}." 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, bid_id=bid_id,
tx_type=TxTypes.XMR_SWAP_B_LOCK, tx_type=TxTypes.XMR_SWAP_B_LOCK,
txid=b_lock_tx_id, txid=b_lock_tx_id,
vout=0, vout=b_lock_vout,
) )
xmr_swap.b_lock_tx_id = b_lock_tx_id xmr_swap.b_lock_tx_id = b_lock_tx_id
bid.xmr_b_lock_tx.setState(TxStates.TX_SENT) 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: if self._zmq_queue_enabled and self.zmqSubscriber:
try: try:
if self._read_zmq_queue: if self._read_zmq_queue:
topic, message, seq = self.zmqSubscriber.recv_multipart( for _i in range(100):
flags=zmq.NOBLOCK topic, message, seq = self.zmqSubscriber.recv_multipart(
) flags=zmq.NOBLOCK
if topic == b"smsg": )
self.processZmqSmsg(message) if topic == b"smsg":
elif topic == b"hashwtx": self.processZmqSmsg(message)
self.processZmqHashwtx(message) elif topic == b"hashwtx":
self.processZmqHashwtx(message)
except zmq.Again as e: # noqa: F841 except zmq.Again as e: # noqa: F841
pass pass
except Exception as e: except Exception as e:
self.logException(f"smsg zmq {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() self.updateNetwork()
try: try:
@@ -12891,7 +13089,21 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
): ):
continue continue
if len(c["watched_outputs"]) > 0 or len(c["watched_scripts"]): 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 self._last_checked_watched = now
if now - self._last_checked_expired >= self.check_expired_seconds: if now - self._last_checked_expired >= self.check_expired_seconds:
@@ -13250,39 +13462,35 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
display_name = getCoinName(coin_id) display_name = getCoinName(coin_id)
if old_connection_type == "rpc" and new_connection_type == "electrum": 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) migration_result = self._migrateWalletToLiteMode(coin_id)
if migration_result.get("success"): if migration_result.get("success"):
count = migration_result.get("count", 0) count = migration_result.get("count", 0)
self.log.info( self.log.info(
f"Lite wallet ready for {coin_name} with {count} addresses" f"Lite wallet ready for {coin_name} with {count} addresses"
) )
if migration_message: migration_message = (
migration_message += ( f"Lite wallet ready for {display_name} ({count} addresses)."
f" Lite wallet ready ({count} addresses)." )
auto_transfer_now = data.get("auto_transfer_now", False)
if auto_transfer_now:
transfer_result = self._consolidateLegacyFundsToSegwit(
coin_id
) )
else: if transfer_result.get("success"):
migration_message = f"Lite wallet ready for {display_name} ({count} addresses)." 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: else:
error = migration_result.get("error", "unknown") error = migration_result.get("error", "unknown")
reason = migration_result.get("reason", "") reason = migration_result.get("reason", "")
@@ -13333,14 +13541,23 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
f"Transfer failed for {coin_name}: {error}" f"Transfer failed for {coin_name}: {error}"
) )
raise ValueError(f"Transfer failed: {error}") raise ValueError(f"Transfer failed: {error}")
elif reason in ("has_balance", "active_swap"): elif reason == "active_swap":
error = empty_check.get( error = empty_check.get(
"message", "Wallet must be empty before switching modes" "message", "Cannot switch: active swap in progress"
) )
self.log.error( self.log.error(
f"Migration blocked for {coin_name}: {error}" f"Migration blocked for {coin_name}: {error}"
) )
raise ValueError(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) sync_result = self._syncWalletIndicesToRPC(coin_id)
if sync_result.get("success"): if sync_result.get("success"):
@@ -13712,6 +13929,26 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
rv["mweb_pending"] = walletinfo.get( rv["mweb_pending"] = walletinfo.get(
"mweb_unconfirmed", 0 "mweb_unconfirmed", 0
) + walletinfo.get("mweb_immature", 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 return rv
except Exception as e: except Exception as e:
@@ -13719,9 +13956,9 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
def addWalletInfoRecord(self, coin, info_type, wi) -> None: def addWalletInfoRecord(self, coin, info_type, wi) -> None:
coin_id = int(coin) coin_id = int(coin)
now: int = self.getTime()
cursor = self.openDB() cursor = self.openDB()
try: try:
now: int = self.getTime()
self.add( self.add(
Wallets( Wallets(
coin_id=coin, coin_id=coin,
@@ -13975,6 +14212,8 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp):
if row2[0].startswith("stealth"): if row2[0].startswith("stealth"):
if coin_id == Coins.LTC: if coin_id == Coins.LTC:
wallet_data["mweb_address"] = row2[1] wallet_data["mweb_address"] = row2[1]
elif coin_id == Coins.FIRO:
wallet_data["spark_address"] = row2[1]
else: else:
wallet_data["stealth_address"] = row2[1] wallet_data["stealth_address"] = row2[1]
else: else:

View File

@@ -36,22 +36,25 @@ def signal_handler(sig, frame):
os.write( os.write(
sys.stdout.fileno(), f"Signal {sig} detected, ending program.\n".encode("utf-8") 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:
try: if swap_client is not None and not swap_client.chainstate_delay_event.is_set():
from basicswap.ui.page_amm import stop_amm_process, get_amm_status try:
from basicswap.ui.page_amm import stop_amm_process, get_amm_status
amm_status = get_amm_status() amm_status = get_amm_status()
if amm_status == "running": if amm_status == "running":
logger.info("Signal handler stopping AMM process...") logger.info("Signal handler stopping AMM process...")
success, msg = stop_amm_process(swap_client) success, msg = stop_amm_process(swap_client)
if success: if success:
logger.info(f"AMM signal shutdown: {msg}") logger.info(f"AMM signal shutdown: {msg}")
else: else:
logger.warning(f"AMM signal shutdown warning: {msg}") logger.warning(f"AMM signal shutdown warning: {msg}")
except Exception as e: except Exception as e:
logger.error(f"Error stopping AMM in signal handler: {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): def checkPARTZmqConfigBeforeStart(part_settings, swap_settings):
@@ -618,7 +621,7 @@ def runClient(
signal.CTRL_C_EVENT if os.name == "nt" else signal.SIGINT signal.CTRL_C_EVENT if os.name == "nt" else signal.SIGINT
) )
except Exception as e: 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: for d in daemons:
try: try:
d.handle.wait(timeout=120) d.handle.wait(timeout=120)
@@ -627,10 +630,12 @@ def runClient(
fp.close() fp.close()
closed_pids.append(d.handle.pid) closed_pids.append(d.handle.pid)
except Exception as e: 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 fail_code: int = swap_client.fail_code
del swap_client swap_client = None
if os.path.exists(pids_path): if os.path.exists(pids_path):
with open(pids_path) as fd: with open(pids_path) as fd:

View File

@@ -6,8 +6,10 @@
# file LICENSE or http://www.opensource.org/licenses/mit-license.php. # file LICENSE or http://www.opensource.org/licenses/mit-license.php.
import os import os
import gzip
import json import json
import shlex import shlex
import hashlib
import secrets import secrets
import traceback import traceback
import threading import threading
@@ -19,6 +21,7 @@ from jinja2 import Environment, PackageLoader
from socket import error as SocketError from socket import error as SocketError
from urllib import parse from urllib import parse
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from email.utils import formatdate, parsedate_to_datetime
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from . import __version__ from . import __version__
@@ -802,7 +805,6 @@ class HttpHandler(BaseHTTPRequestHandler):
if page == "static": if page == "static":
try: try:
static_path = os.path.join(os.path.dirname(__file__), "static") static_path = os.path.join(os.path.dirname(__file__), "static")
content = None
mime_type = "" mime_type = ""
filepath = "" filepath = ""
if len(url_split) > 3 and url_split[2] == "sequence_diagrams": if len(url_split) > 3 and url_split[2] == "sequence_diagrams":
@@ -835,9 +837,73 @@ class HttpHandler(BaseHTTPRequestHandler):
if mime_type == "" or not filepath: if mime_type == "" or not filepath:
raise ValueError("Unknown file type or path") 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: with open(filepath, "rb") as fp:
content = fp.read() 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 return content
except FileNotFoundError: except FileNotFoundError:

View File

@@ -15,6 +15,7 @@ import os
import shutil import shutil
import sqlite3 import sqlite3
import threading import threading
import time
import traceback import traceback
from io import BytesIO from io import BytesIO
@@ -185,6 +186,7 @@ def extractScriptLockRefundScriptValues(script_bytes: bytes):
class BTCInterface(Secp256k1Interface): class BTCInterface(Secp256k1Interface):
_scantxoutset_lock = threading.Lock() _scantxoutset_lock = threading.Lock()
_MAX_SCANTXOUTSET_RETRIES = 3
@staticmethod @staticmethod
def coin_type(): def coin_type():
@@ -233,6 +235,10 @@ class BTCInterface(Secp256k1Interface):
def xmr_swap_b_lock_spend_tx_vsize() -> int: def xmr_swap_b_lock_spend_tx_vsize() -> int:
return 110 return 110
@staticmethod
def getdustlimit() -> int:
return 5460
@staticmethod @staticmethod
def txoType(): def txoType():
return CTxOut return CTxOut
@@ -759,6 +765,19 @@ class BTCInterface(Secp256k1Interface):
wm.syncBalances( wm.syncBalances(
self.coin_type(), self._backend, funded_only=not do_full_scan 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: finally:
if hasattr(self._backend, "setBackgroundMode"): if hasattr(self._backend, "setBackgroundMode"):
self._backend.setBackgroundMode(False) 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_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_fee = max(round(feerate_satkb * rough_vsize / 1000), min_relay_fee)
rough_change = total_input - total_output - rough_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()) change_addr = wm.getNewInternalAddress(self.coin_type())
if not change_addr: if not change_addr:
change_addr = wm.getExistingInternalAddress(self.coin_type()) 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) final_fee = max(round(feerate_satkb * final_vsize / 1000), min_relay_fee)
change = total_input - total_output - final_fee change = total_input - total_output - final_fee
if change > 1000: if change > dust_limit:
funded_tx.vout[-1].nValue = change funded_tx.vout[-1].nValue = change
else: else:
funded_tx.vout.pop() funded_tx.vout.pop()
@@ -2418,10 +2438,37 @@ class BTCInterface(Secp256k1Interface):
def getPkDest(self, K: bytes) -> bytearray: def getPkDest(self, K: bytes) -> bytearray:
return self.getScriptForPubkeyHash(self.getPubkeyHash(K)) 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): def scanTxOutset(self, dest):
if self._connection_type == "electrum": if self._connection_type == "electrum":
return self._scanTxOutsetElectrum(dest) 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): def _scanTxOutsetElectrum(self, dest):
backend = self.getBackend() backend = self.getBackend()
@@ -2572,15 +2619,19 @@ class BTCInterface(Secp256k1Interface):
def encodeSharedAddress(self, Kbv, Kbs): def encodeSharedAddress(self, Kbv, Kbs):
return self.pubkey_to_segwit_address(Kbs) return self.pubkey_to_segwit_address(Kbs)
def publishBLockTx( def publishBLockTx(self, kbv, Kbs, output_amount, feerate, unlock_time: int = 0):
self, kbv, Kbs, output_amount, feerate, unlock_time: int = 0
) -> bytes:
b_lock_tx = self.createBLockTx(Kbs, output_amount) b_lock_tx = self.createBLockTx(Kbs, output_amount)
b_lock_tx = self.fundTx(b_lock_tx, feerate) 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) 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: def getTxVSize(self, tx, add_bytes: int = 0, add_witness_bytes: int = 0) -> int:
wsf = self.witnessScaleFactor() wsf = self.witnessScaleFactor()
@@ -2604,7 +2655,9 @@ class BTCInterface(Secp256k1Interface):
if self.using_segwit() if self.using_segwit()
else self.pubkey_to_address(Kbs) 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) raw_dest = self.getPkDest(Kbs)
@@ -2646,44 +2699,66 @@ class BTCInterface(Secp256k1Interface):
self._log.id(chain_b_lock_txid), lock_tx_vout self._log.id(chain_b_lock_txid), lock_tx_vout
) )
) )
locked_n = lock_tx_vout
Kbs = self.getPubkey(kbs) Kbs = self.getPubkey(kbs)
script_pk = self.getPkDest(Kbs) script_pk = self.getPkDest(Kbs)
if locked_n is None: locked_n = None
if self.useBackend(): actual_value = None
backend = self.getBackend() if self.useBackend():
tx_hex = backend.getTransactionRaw(chain_b_lock_txid.hex()) backend = self.getBackend()
if tx_hex: tx_hex = backend.getTransactionRaw(chain_b_lock_txid.hex())
lock_tx = self.loadTx(bytes.fromhex(tx_hex)) if tx_hex:
locked_n = findOutput(lock_tx, script_pk) lock_tx = self.loadTx(bytes.fromhex(tx_hex))
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 = findOutput(lock_tx, script_pk) 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") 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) pkh_to = self.decodeAddress(address_to)
tx = CTransaction() tx = CTransaction()
@@ -2699,16 +2774,14 @@ class BTCInterface(Secp256k1Interface):
scriptSig=self.getScriptScriptSig(script_lock), scriptSig=self.getScriptScriptSig(script_lock),
) )
) )
tx.vout.append( tx.vout.append(self.txoType()(spend_value, self.getScriptForPubkeyHash(pkh_to)))
self.txoType()(cb_swap_value, self.getScriptForPubkeyHash(pkh_to))
)
pay_fee = self.getBLockSpendTxFee(tx, b_fee) 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 = tx.serialize()
b_lock_spend_tx = self.signTxWithKey( 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)) 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) return self._getOutputElectrum(txid, dest_script, expect_value, xmr_swap)
# TODO: Use getrawtransaction if txindex is active # TODO: Use getrawtransaction if txindex is active
utxos = self.rpc( utxos = self._rpc_scantxoutset(["raw({})".format(dest_script.hex())])
"scantxoutset", ["start", ["raw({})".format(dest_script.hex())]]
)
if "height" in utxos: # chain_height not returned by v18 codebase if "height" in utxos: # chain_height not returned by v18 codebase
chain_height = utxos["height"] chain_height = utxos["height"]
else: else:
@@ -3492,13 +3563,12 @@ class BTCInterface(Secp256k1Interface):
sum_unspent = 0 sum_unspent = 0
with BTCInterface._scantxoutset_lock: self._log.debug("scantxoutset start")
self._log.debug("scantxoutset start") ro = self._rpc_scantxoutset(["addr({})".format(address)])
ro = self.rpc("scantxoutset", ["start", ["addr({})".format(address)]]) self._log.debug("scantxoutset end")
self._log.debug("scantxoutset end")
for o in ro["unspents"]: for o in ro["unspents"]:
sum_unspent += self.make_int(o["amount"]) sum_unspent += self.make_int(o["amount"])
return sum_unspent return sum_unspent
def _getUTXOBalanceElectrum(self, address: str): def _getUTXOBalanceElectrum(self, address: str):
@@ -3881,7 +3951,7 @@ class BTCInterface(Secp256k1Interface):
self.rpc("loadwallet", [self._rpc_wallet]) 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: if check_seed is False or seed_id_before == "Not found" or walletpath is None:
return return
@@ -4005,7 +4075,9 @@ class BTCInterface(Secp256k1Interface):
if self.isWalletEncrypted(): if self.isWalletEncrypted():
raise ValueError("Old password must be set") raise ValueError("Old password must be set")
return self.encryptWallet(new_password, check_seed=check_seed_if_encrypt) 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: def unlockWallet(self, password: str, check_seed: bool = True) -> None:
if password == "": if password == "":
@@ -4038,12 +4110,9 @@ class BTCInterface(Secp256k1Interface):
try: try:
seed_id = self.getWalletSeedID() seed_id = self.getWalletSeedID()
self._log.debug(
f"{self.ticker()} unlockWallet getWalletSeedID returned: {seed_id}"
)
needs_seed_init = seed_id == "Not found" needs_seed_init = seed_id == "Not found"
except Exception as e: 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 needs_seed_init = True
if needs_seed_init: if needs_seed_init:
self._log.info(f"Initializing HD seed for {self.coin_name()}.") self._log.info(f"Initializing HD seed for {self.coin_name()}.")
@@ -4051,11 +4120,9 @@ class BTCInterface(Secp256k1Interface):
if password: if password:
self._log.info(f"Encrypting {self.coin_name()} wallet.") self._log.info(f"Encrypting {self.coin_name()} wallet.")
try: try:
self.rpc_wallet("encryptwallet", [password]) self.rpc_wallet("encryptwallet", [password], timeout=120)
except Exception as e: except Exception as e:
self._log.debug(f"encryptwallet returned: {e}") self._log.debug(f"encryptwallet returned: {e}")
import time
for i in range(10): for i in range(10):
time.sleep(1) time.sleep(1)
try: try:
@@ -4072,7 +4139,7 @@ class BTCInterface(Secp256k1Interface):
check_seed = False check_seed = False
if self.isWalletEncrypted(): if self.isWalletEncrypted():
self.rpc_wallet("walletpassphrase", [password, 100000000]) self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
if check_seed: if check_seed:
self._sc.checkWalletSeed(self.coin_type()) self._sc.checkWalletSeed(self.coin_type())
@@ -4080,7 +4147,15 @@ class BTCInterface(Secp256k1Interface):
self._log.info(f"lockWallet - {self.ticker()}") self._log.info(f"lockWallet - {self.ticker()}")
if self.useBackend(): if self.useBackend():
return 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: def get_p2sh_script_pubkey(self, script: bytearray) -> bytearray:
script_hash = hash160(script) script_hash = hash160(script)

View File

@@ -132,7 +132,7 @@ class DASHInterface(BTCInterface):
self.unlockWallet(old_password, check_seed=False) self.unlockWallet(old_password, check_seed=False)
seed_id_before: str = self.getWalletSeedID() 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": if check_seed is False or seed_id_before == "Not found":
return return
@@ -156,4 +156,6 @@ class DASHInterface(BTCInterface):
if self.isWalletEncrypted(): if self.isWalletEncrypted():
raise ValueError("Old password must be set") raise ValueError("Old password must be set")
return self.encryptWallet(old_password, new_password, check_seed_if_encrypt) 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
)

View File

@@ -188,6 +188,10 @@ class DCRInterface(Secp256k1Interface):
def coin_type(): def coin_type():
return Coins.DCR return Coins.DCR
@staticmethod
def useBackend() -> bool:
return False
@staticmethod @staticmethod
def exp() -> int: def exp() -> int:
return 8 return 8
@@ -364,7 +368,9 @@ class DCRInterface(Secp256k1Interface):
# Read initial pwd from settings # Read initial pwd from settings
settings = self._sc.getChainClientSettings(self.coin_type()) settings = self._sc.getChainClientSettings(self.coin_type())
old_password = settings["wallet_pwd"] 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 # Lock wallet to match other coins
self.rpc_wallet("walletlock") self.rpc_wallet("walletlock")
@@ -378,7 +384,7 @@ class DCRInterface(Secp256k1Interface):
self._log.info("unlockWallet - {}".format(self.ticker())) self._log.info("unlockWallet - {}".format(self.ticker()))
# Max timeout value, ~3 years # Max timeout value, ~3 years
self.rpc_wallet("walletpassphrase", [password, 100000000]) self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
if check_seed: if check_seed:
self._sc.checkWalletSeed(self.coin_type()) self._sc.checkWalletSeed(self.coin_type())
@@ -1064,6 +1070,9 @@ class DCRInterface(Secp256k1Interface):
def describeTx(self, tx_hex: str): def describeTx(self, tx_hex: str):
return self.rpc("decoderawtransaction", [tx_hex]) 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: def fundTx(self, tx: bytes, feerate) -> bytes:
feerate_str = float(self.format_amount(feerate)) feerate_str = float(self.format_amount(feerate))
# TODO: unlock unspents if bid cancelled # TODO: unlock unspents if bid cancelled
@@ -1732,15 +1741,19 @@ class DCRInterface(Secp256k1Interface):
tx.vout.append(self.txoType()(output_amount, script_pk)) tx.vout.append(self.txoType()(output_amount, script_pk))
return tx.serialize() return tx.serialize()
def publishBLockTx( def publishBLockTx(self, kbv, Kbs, output_amount, feerate, unlock_time: int = 0):
self, kbv, Kbs, output_amount, feerate, unlock_time: int = 0
) -> bytes:
b_lock_tx = self.createBLockTx(Kbs, output_amount) b_lock_tx = self.createBLockTx(Kbs, output_amount)
b_lock_tx = self.fundTx(b_lock_tx, feerate) 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) 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: def getBLockSpendTxFee(self, tx, fee_rate: int) -> int:
witness_bytes = 115 witness_bytes = 115
@@ -1764,26 +1777,53 @@ class DCRInterface(Secp256k1Interface):
lock_tx_vout=None, lock_tx_vout=None,
) -> bytes: ) -> bytes:
self._log.info("spendBLockTx %s:\n", chain_b_lock_txid.hex()) self._log.info("spendBLockTx %s:\n", chain_b_lock_txid.hex())
locked_n = lock_tx_vout
Kbs = self.getPubkey(kbs) Kbs = self.getPubkey(kbs)
script_pk = self.getPkDest(Kbs) script_pk = self.getPkDest(Kbs)
if locked_n is None: locked_n = None
self._log.debug( actual_value = None
f"Unknown lock vout, searching tx: {chain_b_lock_txid.hex()}" 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 for i, out in enumerate(lock_tx.vout):
wtx = self.rpc_wallet( self._log.debug(
"gettransaction", f" vout[{i}]: value={out.value}, scriptPubKey={out.scriptPubKey.hex()}"
[ )
chain_b_lock_txid.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") 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) pkh_to = self.decodeAddress(address_to)
tx = CTransaction() tx = CTransaction()
@@ -1792,10 +1832,10 @@ class DCRInterface(Secp256k1Interface):
chain_b_lock_txid_int = b2i(chain_b_lock_txid) 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.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) 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 = tx.serialize()
b_lock_spend_tx = self.signTxWithKey(b_lock_spend_tx, kbs) b_lock_spend_tx = self.signTxWithKey(b_lock_spend_tx, kbs)

View File

@@ -119,7 +119,8 @@ class ElectrumConnection:
self._socket = None self._socket = None
self._connected = False self._connected = False
_close_socket_safe(sock) _close_socket_safe(sock)
for q in self._response_queues.values(): queues = list(self._response_queues.values())
for q in queues:
try: try:
q.put({"error": "Connection closed"}) q.put({"error": "Connection closed"})
except Exception: except Exception:
@@ -305,17 +306,26 @@ class ElectrumConnection:
results = {} results = {}
deadline = time.time() + timeout deadline = time.time() + timeout
for req_id in expected_ids: for req_id in expected_ids:
remaining = deadline - time.time() response = None
if remaining <= 0: while response is None:
raise TemporaryError("Batch request timed out") 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: try:
response = self._response_queues[req_id].get(timeout=remaining)
if "error" in response and response["error"]: 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"]} results[req_id] = {"error": response["error"]}
else: else:
results[req_id] = {"result": response.get("result")} results[req_id] = {"result": response.get("result")}
except queue.Empty:
raise TemporaryError("Batch request timed out")
finally: finally:
self._response_queues.pop(req_id, None) self._response_queues.pop(req_id, None)
return results return results
@@ -329,13 +339,13 @@ class ElectrumConnection:
self._request_id += 1 self._request_id += 1
request_id = self._request_id request_id = self._request_id
self._response_queues[request_id] = queue.Queue() self._response_queues[request_id] = queue.Queue()
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": request_id, "id": request_id,
"method": method, "method": method,
"params": params if params else [], "params": params if params else [],
} }
self._socket.sendall((json.dumps(request) + "\n").encode()) self._socket.sendall((json.dumps(request) + "\n").encode())
result = self._receive_response_async(request_id, timeout=timeout) result = self._receive_response_async(request_id, timeout=timeout)
return result return result
else: else:
@@ -470,6 +480,7 @@ class ElectrumServer:
self._connection = None self._connection = None
self._current_server_idx = 0 self._current_server_idx = 0
self._lock = threading.Lock() self._lock = threading.Lock()
self._stopping = False
self._server_version = None self._server_version = None
self._current_server_host = None self._current_server_host = None
@@ -492,17 +503,24 @@ class ElectrumServer:
self._server_blacklist = {} self._server_blacklist = {}
self._rate_limit_backoff = 300 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_thread = None
self._keepalive_running = False self._keepalive_running = False
self._keepalive_interval = 15 self._keepalive_interval = 15
self._last_activity = 0 self._last_activity = 0
self._last_reconnect_time = 0
self._min_request_interval = 0.02 self._min_request_interval = 0.02
self._last_request_time = 0 self._last_request_time = 0
self._bg_connection = None self._user_connection = None
self._bg_lock = threading.Lock() self._user_lock = threading.Lock()
self._bg_last_activity = 0 self._user_last_activity = 0
self._user_connection_logged = False
self._subscribed_height = 0 self._subscribed_height = 0
self._subscribed_height_time = 0 self._subscribed_height_time = 0
@@ -559,6 +577,8 @@ class ElectrumServer:
return self._servers[index % len(self._servers)] return self._servers[index % len(self._servers)]
def connect(self): def connect(self):
if self._stopping:
return
sorted_servers = self.get_sorted_servers() sorted_servers = self.get_sorted_servers()
for server in sorted_servers: for server in sorted_servers:
try: try:
@@ -576,6 +596,8 @@ class ElectrumServer:
version_info = conn.get_server_version() version_info = conn.get_server_version()
if version_info and len(version_info) > 0: if version_info and len(version_info) > 0:
self._server_version = 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_host = server["host"]
self._current_server_port = server["port"] self._current_server_port = server["port"]
self._connection = conn self._connection = conn
@@ -585,6 +607,7 @@ class ElectrumServer:
self._all_servers_failed = False self._all_servers_failed = False
self._update_server_score(server, success=True, latency_ms=connect_time) self._update_server_score(server, success=True, latency_ms=connect_time)
self._last_activity = time.time() self._last_activity = time.time()
self._last_reconnect_time = time.time()
if self._log: if self._log:
if not self._initial_connection_logged: if not self._initial_connection_logged:
self._log.info( self._log.info(
@@ -592,11 +615,15 @@ class ElectrumServer:
f"({self._server_version}, {connect_time:.0f}ms)" f"({self._server_version}, {connect_time:.0f}ms)"
) )
self._initial_connection_logged = True self._initial_connection_logged = True
else: elif server["host"] != prev_host or server["port"] != prev_port:
self._log.debug( self._log.info(
f"Reconnected to Electrum server: {server['host']}:{server['port']} " f"Switched to Electrum server: {server['host']}:{server['port']} "
f"({connect_time:.0f}ms)" f"({connect_time:.0f}ms)"
) )
if self._stopping:
conn.disconnect()
self._connection = None
return
if self._realtime_enabled: if self._realtime_enabled:
self._start_realtime_listener() self._start_realtime_listener()
self._start_keepalive() self._start_keepalive()
@@ -609,8 +636,6 @@ class ElectrumServer:
self._update_server_score(server, success=False) self._update_server_score(server, success=False)
if self._is_rate_limit_error(str(e)): if self._is_rate_limit_error(str(e)):
self._blacklist_server(server, str(e)) self._blacklist_server(server, str(e))
if self._log:
self._log.debug(f"Failed to connect to {server['host']}: {e}")
continue continue
self._all_servers_failed = True self._all_servers_failed = True
raise TemporaryError( raise TemporaryError(
@@ -673,11 +698,6 @@ class ElectrumServer:
key = self._get_server_key(s) key = self._get_server_key(s)
if key in self._server_blacklist: if key in self._server_blacklist:
if now < self._server_blacklist[key]: 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 continue
else: else:
del self._server_blacklist[key] del self._server_blacklist[key]
@@ -728,15 +748,14 @@ class ElectrumServer:
if self._connection: if self._connection:
self._connection._start_listener() self._connection._start_listener()
result = self._connection.call( result = self._connection.call(
"blockchain.headers.subscribe", [], timeout=10 "blockchain.headers.subscribe", [], timeout=20
) )
if result and isinstance(result, dict): if result and isinstance(result, dict):
height = result.get("height", 0) height = result.get("height", 0)
if height > 0: if height > 0:
self._on_header_update(height) self._on_header_update(height)
except Exception as e: except Exception:
if self._log: pass
self._log.debug(f"Failed to subscribe to headers: {e}")
def register_height_callback(self, callback): def register_height_callback(self, callback):
self._height_callback = callback self._height_callback = callback
@@ -744,6 +763,11 @@ class ElectrumServer:
def get_subscribed_height(self) -> int: def get_subscribed_height(self) -> int:
return self._subscribed_height 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: def get_server_scores(self) -> dict:
return { return {
self._get_server_key(s): { self._get_server_key(s): {
@@ -781,7 +805,8 @@ class ElectrumServer:
return return
time.sleep(1) 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._connection and self._connection.is_connected():
if self._lock.acquire(blocking=False): if self._lock.acquire(blocking=False):
try: try:
@@ -802,6 +827,8 @@ class ElectrumServer:
self._last_request_time = time.time() self._last_request_time = time.time()
def _retry_on_failure(self): def _retry_on_failure(self):
if self._stopping:
return
self._current_server_idx = (self._current_server_idx + 1) % len(self._servers) self._current_server_idx = (self._current_server_idx + 1) % len(self._servers)
if self._connection: if self._connection:
try: try:
@@ -824,17 +851,27 @@ class ElectrumServer:
return False return False
def call(self, method, params=None, timeout=10): def call(self, method, params=None, timeout=10):
if self._stopping:
raise TemporaryError("Electrum server is shutting down")
self._throttle_request() self._throttle_request()
lock_acquired = self._lock.acquire(timeout=timeout + 5) lock_acquired = self._lock.acquire(timeout=timeout + 5)
if not lock_acquired: if not lock_acquired:
raise TemporaryError(f"Electrum call timed out waiting for lock: {method}") raise TemporaryError(f"Electrum call timed out waiting for lock: {method}")
try: try:
for attempt in range(2): 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(): if self._connection is None or not self._connection.is_connected():
self.connect() self.connect()
if self._connection is None:
raise TemporaryError("Failed to establish Electrum connection")
elif (time.time() - self._last_activity) > 60: elif (time.time() - self._last_activity) > 60:
if not self._check_connection_health(): if not self._check_connection_health():
self._retry_on_failure() self._retry_on_failure()
if self._connection is None:
raise TemporaryError(
"Failed to re-establish Electrum connection"
)
try: try:
result = self._connection.call(method, params, timeout=timeout) result = self._connection.call(method, params, timeout=timeout)
self._last_activity = time.time() self._last_activity = time.time()
@@ -851,17 +888,27 @@ class ElectrumServer:
self._lock.release() self._lock.release()
def call_batch(self, requests, timeout=15): def call_batch(self, requests, timeout=15):
if self._stopping:
raise TemporaryError("Electrum server is shutting down")
self._throttle_request() self._throttle_request()
lock_acquired = self._lock.acquire(timeout=timeout + 5) lock_acquired = self._lock.acquire(timeout=timeout + 5)
if not lock_acquired: if not lock_acquired:
raise TemporaryError("Electrum batch call timed out waiting for lock") raise TemporaryError("Electrum batch call timed out waiting for lock")
try: try:
for attempt in range(2): 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(): if self._connection is None or not self._connection.is_connected():
self.connect() self.connect()
if self._connection is None:
raise TemporaryError("Failed to establish Electrum connection")
elif (time.time() - self._last_activity) > 60: elif (time.time() - self._last_activity) > 60:
if not self._check_connection_health(): if not self._check_connection_health():
self._retry_on_failure() self._retry_on_failure()
if self._connection is None:
raise TemporaryError(
"Failed to re-establish Electrum connection"
)
try: try:
result = self._connection.call_batch(requests) result = self._connection.call_batch(requests)
self._last_activity = time.time() self._last_activity = time.time()
@@ -877,7 +924,9 @@ class ElectrumServer:
finally: finally:
self._lock.release() self._lock.release()
def _connect_background(self): def _connect_user(self):
if self._stopping:
return False
sorted_servers = self.get_sorted_servers() sorted_servers = self.get_sorted_servers()
for server in sorted_servers: for server in sorted_servers:
try: try:
@@ -890,105 +939,213 @@ class ElectrumServer:
proxy_port=self._proxy_port, proxy_port=self._proxy_port,
) )
conn.connect() conn.connect()
self._bg_connection = conn conn.get_server_version()
self._user_connection = conn
self._user_last_activity = time.time()
if self._log: if self._log:
self._log.debug( if not self._user_connection_logged:
f"Background connection established to {server['host']}" 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 return True
except Exception as e: except Exception as e:
if self._log: if self._log:
self._log.debug( self._log.debug(f"User connection failed to {server['host']}: {e}")
f"Background connection failed to {server['host']}: {e}"
)
continue continue
return False return False
def call_background(self, method, params=None, timeout=10): def _record_timeout(self):
lock_acquired = self._bg_lock.acquire(timeout=1) 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: if not lock_acquired:
return self.call(method, params, timeout) raise TemporaryError(f"User connection busy: {method}")
try: try:
if self._bg_connection is None or not self._bg_connection.is_connected(): if (
if not self._connect_background(): self._user_connection is None
self._bg_lock.release() or not self._user_connection.is_connected()
return self.call(method, params, timeout) ):
if not self._connect_user():
raise TemporaryError("User connection unavailable")
try: try:
result = self._bg_connection.call(method, params, timeout=timeout) result = self._user_connection.call(method, params, timeout=timeout)
self._bg_last_activity = time.time() self._user_last_activity = time.time()
return result return result
except Exception: except Exception as e:
if self._bg_connection: if self._log:
self._log.debug(f"User call failed ({method}): {e}")
if self._user_connection:
try: try:
self._bg_connection.disconnect() self._user_connection.disconnect()
except Exception: except Exception:
pass pass
self._bg_connection = None self._user_connection = None
if self._connect_background(): if self._connect_user():
try: try:
result = self._bg_connection.call( result = self._user_connection.call(
method, params, timeout=timeout method, params, timeout=timeout
) )
self._bg_last_activity = time.time() self._user_last_activity = time.time()
return result return result
except Exception: except Exception as e2:
pass raise TemporaryError(f"User call failed: {e2}")
return self.call(method, params, timeout) raise TemporaryError(f"User call failed: {e}")
finally: finally:
self._bg_lock.release() self._user_lock.release()
def call_batch_background(self, requests, timeout=15): def call_batch_user(self, requests, timeout=15):
lock_acquired = self._bg_lock.acquire(timeout=1) if self._stopping:
raise TemporaryError("Electrum server is shutting down")
lock_acquired = self._user_lock.acquire(timeout=timeout + 2)
if not lock_acquired: if not lock_acquired:
return self.call_batch(requests, timeout) raise TemporaryError("User connection busy")
try: try:
if self._bg_connection is None or not self._bg_connection.is_connected(): if (
if not self._connect_background(): self._user_connection is None
self._bg_lock.release() or not self._user_connection.is_connected()
return self.call_batch(requests, timeout) ):
if not self._connect_user():
raise TemporaryError("User connection unavailable")
try: try:
result = self._bg_connection.call_batch(requests) result = self._user_connection.call_batch(requests)
self._bg_last_activity = time.time() self._user_last_activity = time.time()
return result return result
except Exception: except Exception as e:
if self._bg_connection: if self._log:
self._log.debug(f"User batch call failed: {e}")
if self._user_connection:
try: try:
self._bg_connection.disconnect() self._user_connection.disconnect()
except Exception: except Exception:
pass pass
self._bg_connection = None self._user_connection = None
if self._connect_background(): if self._connect_user():
try: try:
result = self._bg_connection.call_batch(requests) result = self._user_connection.call_batch(requests)
self._bg_last_activity = time.time() self._user_last_activity = time.time()
return result return result
except Exception: except Exception as e2:
pass raise TemporaryError(f"User batch call failed: {e2}")
return self.call_batch(requests, timeout) raise TemporaryError(f"User batch call failed: {e}")
finally: finally:
self._bg_lock.release() self._user_lock.release()
def disconnect(self): def disconnect(self):
self._stop_keepalive() self._stop_keepalive()
with self._lock: lock_acquired = self._lock.acquire(timeout=5)
if self._connection: if lock_acquired:
self._connection.disconnect() try:
self._connection = None if self._connection:
with self._bg_lock: self._connection.disconnect()
if self._bg_connection: self._connection = None
finally:
self._lock.release()
else:
conn = self._connection
if conn:
try: try:
self._bg_connection.disconnect() conn.disconnect()
except Exception: except Exception:
pass 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): def get_balance(self, scripthash):
result = self.call("blockchain.scripthash.get_balance", [scripthash]) result = self.call("blockchain.scripthash.get_balance", [scripthash])

View File

@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright (c) 2022-2023 tecnovert # 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 # Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php. # file LICENSE or http://www.opensource.org/licenses/mit-license.php.
@@ -64,7 +64,7 @@ class FIROInterface(BTCInterface):
# Firo shuts down after encryptwallet # Firo shuts down after encryptwallet
seed_id_before: str = self.getWalletSeedID() if check_seed else "Not found" 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": if check_seed is False or seed_id_before == "Not found":
return return
@@ -102,6 +102,100 @@ class FIROInterface(BTCInterface):
return addr_info["ismine"] return addr_info["ismine"]
return addr_info["ismine"] or addr_info["iswatchonly"] 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: def getSCLockScriptAddress(self, lock_script: bytes) -> str:
lock_tx_dest = self.getScriptDest(lock_script) lock_tx_dest = self.getScriptDest(lock_script)
address = self.encodeScriptDest(lock_tx_dest) address = self.encodeScriptDest(lock_tx_dest)
@@ -252,10 +346,6 @@ class FIROInterface(BTCInterface):
assert len(script_hash) == 20 assert len(script_hash) == 20
return CScript([OP_HASH160, script_hash, OP_EQUAL]) 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): def getWalletSeedID(self):
return self.rpc("getwalletinfo")["hdmasterkeyid"] return self.rpc("getwalletinfo")["hdmasterkeyid"]

View File

@@ -209,11 +209,9 @@ class LTCInterface(BTCInterface):
try: try:
seed_id = self.getWalletSeedID() seed_id = self.getWalletSeedID()
self._log.debug(f"LTC unlockWallet getWalletSeedID returned: {seed_id}")
needs_seed_init = seed_id == "Not found" needs_seed_init = seed_id == "Not found"
except Exception as e: except Exception as e:
self._log.debug(f"getWalletSeedID failed: {e}")
self._log.debug(f"getWalletSeedID failed: {e}, will initialize seed")
needs_seed_init = True needs_seed_init = True
if needs_seed_init: if needs_seed_init:
self._log.info(f"Initializing HD seed for {self.coin_name()}.") self._log.info(f"Initializing HD seed for {self.coin_name()}.")
@@ -221,7 +219,7 @@ class LTCInterface(BTCInterface):
if password: if password:
self._log.info(f"Encrypting {self.coin_name()} wallet.") self._log.info(f"Encrypting {self.coin_name()} wallet.")
try: try:
self.rpc_wallet("encryptwallet", [password]) self.rpc_wallet("encryptwallet", [password], timeout=120)
except Exception as e: except Exception as e:
self._log.debug(f"encryptwallet returned: {e}") self._log.debug(f"encryptwallet returned: {e}")
import time import time
@@ -242,7 +240,7 @@ class LTCInterface(BTCInterface):
check_seed = False check_seed = False
if self.isWalletEncrypted(): if self.isWalletEncrypted():
self.rpc_wallet("walletpassphrase", [password, 100000000]) self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
if check_seed: if check_seed:
self._sc.checkWalletSeed(self.coin_type()) self._sc.checkWalletSeed(self.coin_type())
@@ -332,7 +330,7 @@ class LTCInterfaceMWEB(LTCInterface):
if password is not None: if password is not None:
# Max timeout value, ~3 years # Max timeout value, ~3 years
self.rpc_wallet("walletpassphrase", [password, 100000000]) self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
if self.getWalletSeedID() == "Not found": if self.getWalletSeedID() == "Not found":
self._sc.initialiseWallet(self.interface_type()) self._sc.initialiseWallet(self.interface_type())
@@ -341,7 +339,7 @@ class LTCInterfaceMWEB(LTCInterface):
self.rpc("unloadwallet", ["mweb"]) self.rpc("unloadwallet", ["mweb"])
self.rpc("loadwallet", ["mweb"]) self.rpc("loadwallet", ["mweb"])
if password is not None: if password is not None:
self.rpc_wallet("walletpassphrase", [password, 100000000]) self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
self.rpc_wallet("keypoolrefill") self.rpc_wallet("keypoolrefill")
def unlockWallet(self, password: str, check_seed: bool = True) -> None: def unlockWallet(self, password: str, check_seed: bool = True) -> None:
@@ -355,15 +353,12 @@ class LTCInterfaceMWEB(LTCInterface):
if not self.has_mweb_wallet(): if not self.has_mweb_wallet():
self.init_wallet(password) self.init_wallet(password)
else: else:
self.rpc_wallet("walletpassphrase", [password, 100000000]) self.rpc_wallet("walletpassphrase", [password, 100000000], timeout=120)
try: try:
seed_id = self.getWalletSeedID() seed_id = self.getWalletSeedID()
self._log.debug(
f"LTC_MWEB unlockWallet getWalletSeedID returned: {seed_id}"
)
needs_seed_init = seed_id == "Not found" needs_seed_init = seed_id == "Not found"
except Exception as e: 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 needs_seed_init = True
if needs_seed_init: if needs_seed_init:
self._log.info(f"Initializing HD seed for {self.coin_name()}.") self._log.info(f"Initializing HD seed for {self.coin_name()}.")

View File

@@ -40,7 +40,7 @@ class PIVXInterface(BTCInterface):
seed_id_before: str = self.getWalletSeedID() 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": if check_seed is False or seed_id_before == "Not found":
return return

View File

@@ -79,9 +79,11 @@ def withdraw_coin(swap_client, coin_type, post_string, is_json):
txid_hex = swap_client.withdrawParticl( txid_hex = swap_client.withdrawParticl(
type_from, type_to, value, address, subfee 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") 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): elif coin_type in (Coins.XMR, Coins.WOW):
txid_hex = swap_client.withdrawCoin(coin_type, value, address, sweepall) txid_hex = swap_client.withdrawCoin(coin_type, value, address, sweepall)
else: else:
@@ -181,6 +183,15 @@ def js_walletbalances(self, url_split, post_string, is_json) -> bytes:
version = ci.getDaemonVersion() version = ci.getDaemonVersion()
if version: if version:
coin_entry["version"] = 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) 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}" wallet_seed_id = f"Error: {e}"
rv.update( rv.update(
{ {
"seed": seed_key.hex(),
"seed_id": seed_id.hex(), "seed_id": seed_id.hex(),
"expected_seed_id": "Unset" if expect_seedid is None else expect_seedid, "expected_seed_id": "Unset" if expect_seedid is None else expect_seedid,
"current_seed_id": wallet_seed_id, "current_seed_id": wallet_seed_id,
@@ -1739,38 +1749,57 @@ def js_modeswitchinfo(self, url_split, post_string, is_json) -> bytes:
} }
if direction == "lite": if direction == "lite":
legacy_balance_sats = 0 non_bip84_balance_sats = 0
has_legacy_funds = False has_non_bip84_funds = False
try: try:
if hasattr(ci, "rpc_wallet"): if hasattr(ci, "rpc_wallet"):
unspent = ci.rpc_wallet("listunspent") 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["show_transfer_option"] = True
rv["require_transfer"] = True rv["require_transfer"] = True
rv["legacy_balance_sats"] = legacy_balance_sats rv["legacy_balance_sats"] = non_bip84_balance_sats
rv["legacy_balance"] = ci.format_amount(legacy_balance_sats) rv["legacy_balance"] = ci.format_amount(non_bip84_balance_sats)
rv["message"] = ( 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: else:
rv["show_transfer_option"] = False rv["show_transfer_option"] = False
rv["require_transfer"] = False rv["require_transfer"] = False
if has_legacy_funds: if has_non_bip84_funds:
rv["legacy_balance_sats"] = legacy_balance_sats rv["legacy_balance_sats"] = non_bip84_balance_sats
rv["legacy_balance"] = ci.format_amount(legacy_balance_sats) rv["legacy_balance"] = ci.format_amount(non_bip84_balance_sats)
rv["message"] = "Legacy balance too low to transfer" rv["message"] = "Non-derivable balance too low to transfer"
else: else:
rv["legacy_balance_sats"] = 0 rv["legacy_balance_sats"] = 0
rv["legacy_balance"] = "0" rv["legacy_balance"] = "0"
rv["message"] = "All funds on native segwit addresses" rv["message"] = "All funds on BIP84 addresses"
else: else:
rv["show_transfer_option"] = can_transfer rv["show_transfer_option"] = can_transfer
if balance_sats == 0: if balance_sats == 0:

View File

@@ -152,15 +152,17 @@ class Jsonrpc:
pass 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: 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: try:
url = "http://{}@{}:{}/".format(auth, host, rpc_port) url = "http://{}@{}:{}/".format(auth, host, rpc_port)
if wallet is not None: if wallet is not None:
url += "wallet/" + urllib.parse.quote(wallet) url += "wallet/" + urllib.parse.quote(wallet)
x = Jsonrpc(url) x = Jsonrpc(url, timeout=timeout if timeout else 10)
v = x.json_request(method, params) v = x.json_request(method, params)
x.close() x.close()
@@ -174,7 +176,9 @@ def callrpc(rpc_port, auth, method, params=[], wallet=None, host="127.0.0.1"):
return r["result"] 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 from .rpc_pool import get_rpc_pool
import http.client import http.client
import socket 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: if wallet is not None:
url += "wallet/" + urllib.parse.quote(wallet) 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) max_connections = _rpc_pool_settings.get("max_connections_per_daemon", 5)
pool = get_rpc_pool(url, max_connections) 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 wallet = wallet
host = host host = host
def rpc_func(method, params=None, wallet_override=None): def rpc_func(method, params=None, wallet_override=None, timeout=None):
return callrpc( return callrpc(
port, port,
auth, auth,
@@ -255,6 +273,7 @@ def make_rpc_func(port, auth, wallet=None, host="127.0.0.1"):
params, params,
wallet if wallet_override is None else wallet_override, wallet if wallet_override is None else wallet_override,
host, host,
timeout=timeout,
) )
return rpc_func return rpc_func

View File

@@ -610,7 +610,7 @@ function ensureToastContainer() {
clickAction = `onclick="window.location.href='/bid/${options.bidId}'"`; clickAction = `onclick="window.location.href='/bid/${options.bidId}'"`;
cursorStyle = 'cursor-pointer'; cursorStyle = 'cursor-pointer';
} else if (options.coinSymbol) { } else if (options.coinSymbol) {
clickAction = `onclick="window.location.href='/wallet/${options.coinSymbol}'"`; clickAction = `onclick="window.location.href='/wallet/${options.coinSymbol.toLowerCase()}'"`;
cursorStyle = 'cursor-pointer'; cursorStyle = 'cursor-pointer';
} else if (options.releaseUrl) { } else if (options.releaseUrl) {
clickAction = `onclick="window.open('${options.releaseUrl}', '_blank')"`; clickAction = `onclick="window.open('${options.releaseUrl}', '_blank')"`;
@@ -739,9 +739,10 @@ function ensureToastContainer() {
case 'sweep_completed': case 'sweep_completed':
const sweepAmount = parseFloat(data.amount || 0).toFixed(8).replace(/\.?0+$/, ''); const sweepAmount = parseFloat(data.amount || 0).toFixed(8).replace(/\.?0+$/, '');
const sweepFee = parseFloat(data.fee || 0).toFixed(8).replace(/\.?0+$/, ''); const sweepFee = parseFloat(data.fee || 0).toFixed(8).replace(/\.?0+$/, '');
toastTitle = `Swept ${sweepAmount} ${data.coin_name} to RPC wallet`; const sweepTicker = data.ticker || data.coin_name;
toastOptions.subtitle = `Fee: ${sweepFee} ${data.coin_name} • TXID: ${(data.txid || '').substring(0, 12)}...`; toastTitle = `Swept ${sweepAmount} ${sweepTicker} to RPC wallet`;
toastOptions.coinSymbol = data.coin_name; toastOptions.subtitle = `Fee: ${sweepFee} ${sweepTicker} • TXID: ${(data.txid || '').substring(0, 12)}...`;
toastOptions.coinSymbol = sweepTicker;
toastOptions.txid = data.txid; toastOptions.txid = data.txid;
toastType = 'sweep_completed'; toastType = 'sweep_completed';
shouldShowToast = true; shouldShowToast = true;

View File

@@ -1,7 +1,7 @@
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const state = { const state = {
dentities: new Map(), identities: new Map(),
currentPage: 1, currentPage: 1,
wsConnected: false, wsConnected: false,
jsonData: [], jsonData: [],

View File

@@ -251,15 +251,15 @@
let transferSection = ''; let transferSection = '';
if (info.require_transfer && info.legacy_balance_sats > 0) { if (info.require_transfer && info.legacy_balance_sats > 0) {
transferSection = ` 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"> <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-yellow-800 dark:text-yellow-200 mb-2">Legacy Funds Transfer Required</p> <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-300 mb-2"> <p class="text-xs text-gray-700 dark:text-gray-200 mb-2">
<strong>${info.legacy_balance} ${info.coin}</strong> on legacy addresses will be automatically transferred to a native segwit address. <strong>${info.legacy_balance} ${info.coin}</strong> on non-derivable addresses will be automatically transferred to a BIP84 address.
</p> </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} Est. fee: ${info.estimated_fee} ${info.coin}
</p> </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. This ensures your funds are recoverable using the extended key backup in external Electrum wallets.
</p> </p>
<input type="hidden" name="transfer_choice" value="auto"> <input type="hidden" name="transfer_choice" value="auto">
@@ -267,8 +267,8 @@
`; `;
} else if (info.legacy_balance_sats > 0 && !info.show_transfer_option) { } else if (info.legacy_balance_sats > 0 && !info.show_transfer_option) {
transferSection = ` transferSection = `
<p class="text-yellow-700 dark:text-yellow-300 text-xs mb-3"> <p class="text-gray-700 dark:text-gray-300 text-xs mb-3">
Some funds on legacy addresses (${info.legacy_balance} ${info.coin}) - too low to transfer. Some funds on non-derivable addresses (${info.legacy_balance} ${info.coin}) - too low to transfer.
</p> </p>
`; `;
} }
@@ -280,11 +280,22 @@
</p> </p>
<p class="mb-2 text-gray-800 dark:text-gray-100"><strong>Extended Private Key (for external wallet import):</strong></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"> <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> </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} ${transferSection}
<div class="border-t border-gray-300 dark:border-gray-500 pt-3"> <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"> <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> </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'); const checkbox = document.getElementById('walletModeKeyConfirmCheckbox');
if (checkbox) { if (checkbox) {
checkbox.addEventListener('change', () => this.updateConfirmButtonState()); checkbox.addEventListener('change', () => this.updateConfirmButtonState());
@@ -362,21 +390,21 @@
</p> </p>
<div class="space-y-2"> <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"> <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> <div>
<span class="text-sm font-medium text-gray-900 dark:text-white">Auto-transfer funds to RPC wallet</span> <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> <p class="text-xs text-gray-600 dark:text-gray-300">Recommended. Ensures all funds visible in full node wallet.</p>
</div> </div>
</label> </label>
<label class="flex items-start cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 rounded p-1.5 -m-1"> <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> <div>
<span class="text-sm font-medium text-gray-900 dark:text-white">Keep funds on current addresses</span> <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> <p class="text-xs text-gray-600 dark:text-gray-300">Transfer manually later if needed.</p>
</div> </div>
</label> </label>
</div> </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. If you skip transfer, you will need to manually send funds from lite wallet addresses to your RPC wallet.
</p> </p>
</div> </div>

View File

@@ -87,7 +87,7 @@
} }
} }
if (coinData.scan_status) { if (coinData.scan_status || coinData.electrum_synced !== undefined) {
this.updateScanStatus(coinData); this.updateScanStatus(coinData);
} }
@@ -110,7 +110,7 @@
if (!scanStatusEl) return; if (!scanStatusEl) return;
const status = coinData.scan_status; const status = coinData.scan_status;
if (status.in_progress) { if (status && status.in_progress) {
scanStatusEl.innerHTML = ` scanStatusEl.innerHTML = `
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<span class="text-blue-600 dark:text-blue-300"> <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 class="bg-blue-600 dark:bg-blue-400 h-1 rounded-full transition-all" style="width: ${status.progress}%"></div>
</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 { } else {
scanStatusEl.innerHTML = ` scanStatusEl.innerHTML = `
<div class="flex items-center text-xs text-green-600 dark:text-green-400"> <div class="bg-green-50 dark:bg-gray-500 p-2 rounded">
<svg class="inline-block w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="flex items-center text-xs text-green-600 dark:text-green-400">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> Electrum Wallet Synced
</svg> </div>
Electrum Wallet Synced
</div> </div>
`; `;
} }

View File

@@ -167,29 +167,31 @@
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 space-y-4"> <div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 space-y-4">
<div> <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"> <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>No blockchain download needed - connect via external Electrum servers</li>
<li>Light wallet uses your seed to access existing funds</li> <li>Uses BIP84 derivation (native SegWit) - lower fees, modern addresses (bc1q.../ltc1q...)</li>
<li>No transfer needed - same seed, same funds</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> </ul>
</div> </div>
<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"> <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>Maximum privacy - no external servers, your node validates everything</li>
<li>Any funds you RECEIVE go to these new addresses</li> <li>More wallet features: coin control, RBF, CPFP, raw transactions</li>
<li>Your full node doesn't know about these addresses</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> </ul>
</div> </div>
<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"> <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>To Light: Save your BIP84 key shown during switch (for external wallet import)</li>
<li>• These funds must be SENT back to your node wallet (real transaction, network fee applies based on current rate)</li> <li>• To Full Node: Funds on light wallet addresses must be transferred (network fee applies)</li>
<li>Enable "Auto-transfer" in Fund Transfer section to do this automatically on unlock</li> <li>Both modes share the same seed - switching is safe, just save keys when shown</li>
</ul> </ul>
</div> </div>
@@ -259,13 +261,12 @@
</div> </div>
<div id="fund-transfer-section-{{ c.name }}" class="mb-6 hidden"> <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"> <h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4">
Fund Transfer Pending Balance
</h4> </h4>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4"> <div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
{% if c.lite_wallet_balance %} <div class="p-3 bg-orange-50 dark:bg-orange-900/30 border border-orange-200 dark:border-orange-700 rounded-lg">
<div class="mb-4 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"> <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> <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 Light Wallet Balance Detected
@@ -292,24 +293,24 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %} </div>
<label class="flex items-center cursor-pointer"> {% endif %}
<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>
{% if general_settings.debug %} {% if general_settings.debug %}
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600"> <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> <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"> <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> <span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(5-100)</span>
</div> </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> <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> </div>
{% endif %}
</div> </div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -864,7 +865,7 @@
Switch Mode Switch Mode
</button> </button>
<button type="button" id="walletModeCancel" <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 Cancel
</button> </button>
</div> </div>
@@ -875,4 +876,4 @@
<script src="/static/js/pages/settings-page.js"></script> <script src="/static/js/pages/settings-page.js"></script>
{% include 'footer.html' %} {% include 'footer.html' %}

View File

@@ -35,6 +35,8 @@
</section> </section>
{% endif %} {% endif %}
{% if w.havedata %} {% if w.havedata %}
{% if w.error %} {% if w.error %}
<section class="py-4 px-6" id="messages_error" role="alert"> <section class="py-4 px-6" id="messages_error" role="alert">
@@ -183,8 +185,20 @@
{% endif %} {% endif %}
</td> </td>
</tr> </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 %} {% endif %}
{# / LTC #} {# / LTC #}
{# / FIRO #}
{% if w.locked_utxos %} {% if w.locked_utxos %}
<tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600"> <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> <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 bold">Synced:</td>
<td class="py-3 px-6"> <td class="py-3 px-6">
{% if is_electrum_mode %} {% 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 %} {% else %}
{{ w.synced }} {{ w.synced }}
{% endif %} {% endif %}
@@ -394,8 +412,8 @@
</div> </div>
</div> </div>
</div> </div>
{% if w.cid in '1, 3, 6, 9' %} {% if w.cid in '1, 3, 6, 9, 13' %}
{# PART | LTC | XMR | WOW | #} {# PART | LTC | XMR | WOW | FIRO #}
<div class="w-full md:w-1/2 p-3 flex justify-center items-center"> <div class="w-full md:w-1/2 p-3 flex justify-center items-center">
<div class="h-full"> <div class="h-full">
<div class="flex flex-wrap -m-3"> <div class="flex flex-wrap -m-3">
@@ -444,6 +462,22 @@
</div> </div>
{% endif %} {% endif %}
{# / LTC #} {# / 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 %} {% endif %}
</div> </div>
</div> </div>
@@ -507,6 +541,15 @@
{% endif %} {% endif %}
</td> </td>
</tr> </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' %} {% elif w.cid == '1' %}
{# PART #} {# PART #}
<tr class="opacity-100 text-gray-500 dark:text-gray-100"> <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> <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 #} {# / 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 %} {% 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 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> <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> </div>
</td> </td>
</tr> </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 %} {% endif %}
{# / LTC #} {# / LTC #}
{# / FIRO #}
{% if w.cid not in '6,9' %} {# Not XMR WOW #} {% if w.cid not in '6,9' %} {# Not XMR WOW #}
<tr class="opacity-100 text-gray-500 dark:text-gray-100"> <tr class="opacity-100 text-gray-500 dark:text-gray-100">
<td class="py-3 px-6 bold">Fee Rate:</td> <td class="py-3 px-6 bold">Fee Rate:</td>

View File

@@ -148,6 +148,28 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{# / LTC #} {# / 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"> <hr class="border-t border-gray-100 dark:border-gray-500 my-5">
<div class="flex mb-2 justify-between items-center"> <div class="flex mb-2 justify-between items-center">
<h4 class="text-xs font-medium dark:text-white">Blocks:</h4> <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 class="bg-blue-600 dark:bg-blue-400 h-1 rounded-full" style="width: {{ w.scan_status.progress }}%"></div>
</div> </div>
</div> </div>
{% else %} {% elif w.electrum_synced %}
<div class="bg-green-50 dark:bg-gray-500 p-2 rounded"> <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"> <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>
</div> </div>
{% endif %} {% endif %}

View File

@@ -189,7 +189,7 @@ def page_settings(self, url_split, post_string):
) )
data["auto_transfer_now"] = transfer_value == "true" data["auto_transfer_now"] = transfer_value == "true"
gap_limit_str = get_data_entry_or( gap_limit_str = get_data_entry_or(
form_data, "gap_limit_" + name, "20" form_data, "gap_limit_" + name, "50"
).strip() ).strip()
try: try:
gap_limit = int(gap_limit_str) 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, "supports_electrum": name in electrum_supported_coins,
"clearnet_servers_text": clearnet_text, "clearnet_servers_text": clearnet_text,
"onion_servers_text": onion_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"): if name in ("monero", "wownero"):

View File

@@ -90,6 +90,10 @@ def format_wallet_data(swap_client, ci, w):
wf["mweb_address"] = w.get("mweb_address", "?") wf["mweb_address"] = w.get("mweb_address", "?")
wf["mweb_balance"] = w.get("mweb_balance", "?") wf["mweb_balance"] = w.get("mweb_balance", "?")
wf["mweb_pending"] = w.get("mweb_pending", "?") 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"): if hasattr(ci, "getScanStatus"):
wf["scan_status"] = ci.getScanStatus() wf["scan_status"] = ci.getScanStatus()
@@ -114,6 +118,13 @@ def format_wallet_data(swap_client, ci, w):
except Exception: except Exception:
wf["electrum_connected"] = False wf["electrum_connected"] = False
wf["electrum_status"] = "error" 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) checkAddressesOwned(swap_client, ci, wf)
return wf return wf
@@ -264,6 +275,8 @@ def page_wallet(self, url_split, post_string):
force_refresh = True force_refresh = True
elif have_data_entry(form_data, "newmwebaddr_" + cid): elif have_data_entry(form_data, "newmwebaddr_" + cid):
swap_client.cacheNewStealthAddressForCoin(coin_id) 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): elif have_data_entry(form_data, "reseed_" + cid):
try: try:
swap_client.reseedWallet(coin_id) 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 page_data["wd_type_to_" + cid] = type_to
except Exception as e: # noqa: F841 except Exception as e: # noqa: F841
err_messages.append("Missing type") err_messages.append("Missing type")
elif coin_id == Coins.LTC: elif coin_id in (Coins.LTC, Coins.FIRO):
try: try:
type_from = form_data[bytes("withdraw_type_from_" + cid, "utf-8")][ type_from = form_data[bytes("withdraw_type_from_" + cid, "utf-8")][
0 0
@@ -354,9 +367,9 @@ def page_wallet(self, url_split, post_string):
value, ticker, type_from, type_to, address, txid value, ticker, type_from, type_to, address, txid
) )
) )
elif coin_id == Coins.LTC: elif coin_id in (Coins.LTC, Coins.FIRO):
txid = swap_client.withdrawLTC( txid = swap_client.withdrawCoinExtended(
type_from, value, address, subfee coin_id, type_from, value, address, subfee
) )
messages.append( messages.append(
"Withdrew {} {} (from {}) to address {}<br/>In txid: {}".format( "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: if swap_client.debug is True:
swap_client.log.error(traceback.format_exc()) swap_client.log.error(traceback.format_exc())
is_electrum_mode = (
swap_client.coin_clients.get(coin_id, {}).get("connection_type") == "electrum"
)
swap_client.updateWalletsInfo( 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}) wallets = swap_client.getCachedWalletsInfo({"coin_id": coin_id})
wallet_data = {} wallet_data = {}
@@ -469,6 +486,8 @@ def page_wallet(self, url_split, post_string):
wallet_data["main_address"] = w.get("main_address", "Refresh necessary") wallet_data["main_address"] = w.get("main_address", "Refresh necessary")
elif k == Coins.LTC: elif k == Coins.LTC:
wallet_data["mweb_address"] = w.get("mweb_address", "Refresh necessary") 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: if "wd_type_from_" + cid in page_data:
wallet_data["wd_type_from"] = page_data["wd_type_from_" + cid] wallet_data["wd_type_from"] = page_data["wd_type_from_" + cid]

View File

@@ -197,7 +197,11 @@ class ElectrumBackend(WalletBackend):
self._cached_height_time = 0 self._cached_height_time = 0
self._height_cache_ttl = 5 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 self._background_mode = False
def setBackgroundMode(self, enabled: bool): def setBackgroundMode(self, enabled: bool):
@@ -206,13 +210,20 @@ class ElectrumBackend(WalletBackend):
def _call(self, method: str, params: list = None, timeout: int = 10): def _call(self, method: str, params: list = None, timeout: int = 10):
if self._background_mode and hasattr(self._server, "call_background"): if self._background_mode and hasattr(self._server, "call_background"):
return self._server.call_background(method, params, timeout) 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) return self._server.call(method, params, timeout)
def _call_batch(self, calls: list, timeout: int = 15): def _call_batch(self, calls: list, timeout: int = 15):
if self._background_mode and hasattr(self._server, "call_batch_background"): if self._background_mode and hasattr(self._server, "call_batch_background"):
return self._server.call_batch_background(calls, timeout) 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) return self._server.call_batch(calls, timeout)
def _is_server_stopping(self) -> bool:
return getattr(self._server, "_stopping", False)
def _split_batch_call( def _split_batch_call(
self, scripthashes: list, method: str, batch_size: int = None self, scripthashes: list, method: str, batch_size: int = None
) -> list: ) -> list:
@@ -221,19 +232,30 @@ class ElectrumBackend(WalletBackend):
all_results = [] all_results = []
for i in range(0, len(scripthashes), batch_size): 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] chunk = scripthashes[i : i + batch_size]
try: try:
calls = [(method, [sh]) for sh in chunk] calls = [(method, [sh]) for sh in chunk]
results = self._call_batch(calls) results = self._call_batch(calls)
all_results.extend(results) all_results.extend(results)
except Exception as e: except Exception:
self._log.debug(f"Batch chunk failed ({len(chunk)} items): {e}") if self._is_server_stopping():
self._log.debug(
"_split_batch_call: server stopping after batch failure, aborting"
)
break
for sh in chunk: for sh in chunk:
if self._is_server_stopping():
self._log.debug(
"_split_batch_call: server stopping during fallback, aborting"
)
break
try: try:
result = self._call(method, [sh]) result = self._call(method, [sh])
all_results.append(result) all_results.append(result)
except Exception as e2: except Exception:
self._log.debug(f"Individual call failed for {sh[:8]}...: {e2}")
all_results.append(None) all_results.append(None)
return all_results return all_results
@@ -298,8 +320,10 @@ class ElectrumBackend(WalletBackend):
if not addr_list: if not addr_list:
return result return result
batch_size = 10 batch_size = self._max_batch_size
for batch_start in range(0, len(addr_list), 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] batch = addr_list[batch_start : batch_start + batch_size]
addr_to_scripthash = {} addr_to_scripthash = {}
@@ -332,6 +356,8 @@ class ElectrumBackend(WalletBackend):
batch_success = True batch_success = True
break break
except Exception as e: except Exception as e:
if self._is_server_stopping():
break
if attempt == 0: if attempt == 0:
self._log.debug( self._log.debug(
f"Batch detailed balance query failed, reconnecting: {e}" f"Batch detailed balance query failed, reconnecting: {e}"
@@ -348,6 +374,8 @@ class ElectrumBackend(WalletBackend):
if not batch_success: if not batch_success:
for addr, scripthash in addr_to_scripthash.items(): for addr, scripthash in addr_to_scripthash.items():
if self._is_server_stopping():
break
try: try:
balance = self._call( balance = self._call(
"blockchain.scripthash.get_balance", [scripthash] "blockchain.scripthash.get_balance", [scripthash]
@@ -569,13 +597,22 @@ class ElectrumBackend(WalletBackend):
return self._cached_height if self._cached_height > 0 else 0 return self._cached_height if self._cached_height > 0 else 0
def estimateFee(self, blocks: int = 6) -> int: 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: try:
fee = self._call("blockchain.estimatefee", [blocks]) fee = self._call("blockchain.estimatefee", [blocks])
if fee and fee > 0: if fee and fee > 0:
return int(fee * 1e8 / 1000) result = int(fee * 1e8 / 1000)
return 1 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: except Exception:
return 1 return self._cached_fee.get(cache_key, 1)
def isConnected(self) -> bool: def isConnected(self) -> bool:
try: try:
@@ -616,6 +653,11 @@ class ElectrumBackend(WalletBackend):
status["version"] = self.getServerVersion() status["version"] = self.getServerVersion()
return status 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]: def getAddressHistory(self, address: str) -> List[dict]:
if self._isUnsupportedAddress(address): if self._isUnsupportedAddress(address):
return [] return []
@@ -751,5 +793,29 @@ class ElectrumBackend(WalletBackend):
self._log.debug(f"Failed to subscribe to {address}: {e}") self._log.debug(f"Failed to subscribe to {address}: {e}")
return None 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): def getServer(self):
return self._server return self._server

View File

@@ -37,7 +37,7 @@ class WalletManager:
Coins.LTC: {"mainnet": "ltc", "testnet": "tltc", "regtest": "rltc"}, Coins.LTC: {"mainnet": "ltc", "testnet": "tltc", "regtest": "rltc"},
} }
GAP_LIMIT = 20 GAP_LIMIT = 50
def __init__(self, swap_client, log): def __init__(self, swap_client, log):
self._gap_limits: Dict[Coins, int] = {} self._gap_limits: Dict[Coins, int] = {}
@@ -51,6 +51,12 @@ class WalletManager:
self._migration_in_progress: set = set() self._migration_in_progress: set = set()
self._balance_sync_lock = threading.Lock() 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: def initialize(self, coin_type: Coins, root_key) -> None:
if coin_type not in self.SUPPORTED_COINS: if coin_type not in self.SUPPORTED_COINS:
raise ValueError(f"Coin {coin_type} not supported by WalletManager") raise ValueError(f"Coin {coin_type} not supported by WalletManager")

View File

@@ -1489,7 +1489,11 @@ class Test(BaseTest):
v = ci.getNewRandomKey() v = ci.getNewRandomKey()
s = ci.getNewRandomKey() s = ci.getNewRandomKey()
S = ci.getPubkey(s) 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) test_delay_event.wait(1)
addr_out = ci.getNewAddress(True) addr_out = ci.getNewAddress(True)

View File

@@ -412,7 +412,11 @@ class Test(TestFunctions):
v = ci.getNewRandomKey() v = ci.getNewRandomKey()
s = ci.getNewRandomKey() s = ci.getNewRandomKey()
S = ci.getPubkey(s) 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) test_delay_event.wait(1)
addr_out = ci.getNewAddress(False) addr_out = ci.getNewAddress(False)

View File

@@ -1579,7 +1579,11 @@ class BasicSwapTest(TestFunctions):
v = ci.getNewRandomKey() v = ci.getNewRandomKey()
s = ci.getNewRandomKey() s = ci.getNewRandomKey()
S = ci.getPubkey(s) 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) addr_out = ci.getNewAddress(True)
lock_tx_b_spend_txid = ci.spendBLockTx( lock_tx_b_spend_txid = ci.spendBLockTx(

View File

@@ -1218,7 +1218,11 @@ class Test(BaseTest):
v = ci.getNewRandomKey() v = ci.getNewRandomKey()
s = ci.getNewRandomKey() s = ci.getNewRandomKey()
S = ci.getPubkey(s) 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) addr_out = ci.getNewAddress(True)
lock_tx_b_spend_txid = ci.spendBLockTx( lock_tx_b_spend_txid = ci.spendBLockTx(
@@ -1247,7 +1251,11 @@ class Test(BaseTest):
v = ci.getNewRandomKey() v = ci.getNewRandomKey()
s = ci.getNewRandomKey() s = ci.getNewRandomKey()
S = ci.getPubkey(s) 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) addr_out = ci.getNewAddress(True)
for i in range(20): for i in range(20):
@@ -2306,7 +2314,11 @@ class Test(BaseTest):
v = ci.getNewRandomKey() v = ci.getNewRandomKey()
s = ci.getNewRandomKey() s = ci.getNewRandomKey()
S = ci.getPubkey(s) 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() addr_out = ci.getNewStealthAddress()
lock_tx_b_spend_txid = None lock_tx_b_spend_txid = None