GUI: Dynamic balances (WS) + Better Notifications (Toasts) + various fixes. (#332)

* GUI: Dynamic balances (WS) + various fixes.

* BLACK + FLAKE8

* Clean-up.

* Fix refresh intervals + Fix pending balance.

* Fix amounts scientific notation (1e-8)

* Better Notifications (Toasts)

* Removed duplicated code + Balance skip if the chain is still syncing.

* Fix MWEB doesnt show as pending + Various fixes.

* Fix: USD values are off with part blind.

* Fix: Percentage change buttons on wallet page.

* Cleanup debug on wallet page.

* Use ZMQ for part balances.

* Fix ZMQ config.

* Fix PART price in chart.
This commit is contained in:
Gerlof van Ek
2025-07-22 23:45:45 +02:00
committed by GitHub
parent d6ef4f2edb
commit a5cc83157d
24 changed files with 3199 additions and 302 deletions

View File

@@ -186,6 +186,53 @@ def validOfferStateToReceiveBid(offer_state):
return False
def checkAndNotifyBalanceChange(
swap_client, coin_type, ci, cc, new_height, trigger_source="block"
):
if not swap_client.ws_server:
return
try:
blockchain_info = ci.getBlockchainInfo()
verification_progress = blockchain_info.get("verificationprogress", 1.0)
if verification_progress < 0.99:
return
except Exception:
return
try:
current_balance = ci.getSpendableBalance()
current_total_balance = swap_client.getTotalBalance(coin_type)
cached_balance = cc.get("cached_balance", None)
cached_total_balance = cc.get("cached_total_balance", None)
current_unconfirmed = current_total_balance - current_balance
cached_unconfirmed = cc.get("cached_unconfirmed", None)
if (
cached_balance is None
or current_balance != cached_balance
or cached_total_balance is None
or current_total_balance != cached_total_balance
or cached_unconfirmed is None
or current_unconfirmed != cached_unconfirmed
):
cc["cached_balance"] = current_balance
cc["cached_total_balance"] = current_total_balance
cc["cached_unconfirmed"] = current_unconfirmed
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:
cc["cached_balance"] = None
cc["cached_total_balance"] = None
cc["cached_unconfirmed"] = None
def threadPollXMRChainState(swap_client, coin_type):
ci = swap_client.ci(coin_type)
cc = swap_client.coin_clients[coin_type]
@@ -198,6 +245,11 @@ def threadPollXMRChainState(swap_client, coin_type):
)
with swap_client.mxDB:
cc["chain_height"] = new_height
checkAndNotifyBalanceChange(
swap_client, coin_type, ci, cc, new_height, "block"
)
except Exception as e:
swap_client.log.warning(
f"threadPollXMRChainState {ci.ticker()}, error: {e}"
@@ -219,6 +271,11 @@ def threadPollWOWChainState(swap_client, coin_type):
)
with swap_client.mxDB:
cc["chain_height"] = new_height
checkAndNotifyBalanceChange(
swap_client, coin_type, ci, cc, new_height, "block"
)
except Exception as e:
swap_client.log.warning(
f"threadPollWOWChainState {ci.ticker()}, error: {e}"
@@ -231,6 +288,12 @@ def threadPollWOWChainState(swap_client, coin_type):
def threadPollChainState(swap_client, coin_type):
ci = swap_client.ci(coin_type)
cc = swap_client.coin_clients[coin_type]
if coin_type == Coins.PART and swap_client._zmq_queue_enabled:
poll_delay_range = (40, 60)
else:
poll_delay_range = (20, 30)
while not swap_client.chainstate_delay_event.is_set():
try:
chain_state = ci.getBlockchainInfo()
@@ -244,11 +307,14 @@ def threadPollChainState(swap_client, coin_type):
cc["chain_best_block"] = chain_state["bestblockhash"]
if "mediantime" in chain_state:
cc["chain_median_time"] = chain_state["mediantime"]
checkAndNotifyBalanceChange(
swap_client, coin_type, ci, cc, new_height, "block"
)
except Exception as e:
swap_client.log.warning(f"threadPollChainState {ci.ticker()}, error: {e}")
swap_client.chainstate_delay_event.wait(
random.randrange(20, 30)
) # Random to stagger updates
swap_client.chainstate_delay_event.wait(random.randrange(*poll_delay_range))
class WatchedOutput: # Watch for spends
@@ -498,6 +564,9 @@ class BasicSwap(BaseApp, UIApp):
)
self.zmqSubscriber.setsockopt_string(zmq.SUBSCRIBE, "smsg")
if Coins.PART in chainparams:
self.zmqSubscriber.setsockopt_string(zmq.SUBSCRIBE, "hashwtx")
self.with_coins_override = extra_opts.get("with_coins", set())
self.without_coins_override = extra_opts.get("without_coins", set())
self._force_db_upgrade = extra_opts.get("force_db_upgrade", False)
@@ -5410,6 +5479,39 @@ class BasicSwap(BaseApp, UIApp):
# bid saved in checkBidState
def getTotalBalance(self, coin_type) -> int:
try:
ci = self.ci(coin_type)
if hasattr(ci, "rpc_wallet"):
if coin_type in (Coins.XMR, Coins.WOW):
balance_info = ci.rpc_wallet("get_balance")
return balance_info["balance"]
elif coin_type == Coins.PART:
balances = ci.rpc_wallet("getbalances")
return ci.make_int(
balances["mine"]["trusted"]
+ balances["mine"]["untrusted_pending"]
)
else:
try:
balances = ci.rpc_wallet("getbalances")
return ci.make_int(
balances["mine"]["trusted"]
+ balances["mine"]["untrusted_pending"]
)
except Exception:
wallet_info = ci.rpc_wallet("getwalletinfo")
total = wallet_info.get("balance", 0)
if "unconfirmed_balance" in wallet_info:
total += wallet_info["unconfirmed_balance"]
if "immature_balance" in wallet_info:
total += wallet_info["immature_balance"]
return ci.make_int(total)
else:
return ci.getSpendableBalance()
except Exception:
return ci.getSpendableBalance()
def getAddressBalance(self, coin_type, address: str) -> int:
if self.coin_clients[coin_type]["chain_lookups"] == "explorer":
explorers = self.coin_clients[coin_type]["explorers"]
@@ -10399,6 +10501,29 @@ class BasicSwap(BaseApp, UIApp):
self.processMsg(msg)
def processZmqHashwtx(self) -> None:
self.zmqSubscriber.recv()
try:
if Coins.PART not in self.coin_clients:
return
ci = self.ci(Coins.PART)
cc = self.coin_clients[Coins.PART]
current_height = cc.get("chain_height", 0)
import time
time.sleep(0.1)
checkAndNotifyBalanceChange(self, Coins.PART, ci, cc, current_height, "zmq")
except Exception as e:
self.log.warning(f"Error processing PART wallet transaction: {e}")
if self.debug:
self.log.error(traceback.format_exc())
def expireBidsAndOffers(self, now) -> None:
bids_to_expire = set()
offers_to_expire = set()
@@ -10530,6 +10655,8 @@ class BasicSwap(BaseApp, UIApp):
message = self.zmqSubscriber.recv(flags=zmq.NOBLOCK)
if message == b"smsg":
self.processZmqSmsg()
elif message == b"hashwtx":
self.processZmqHashwtx()
except zmq.Again as e: # noqa: F841
pass
except Exception as e:
@@ -10831,6 +10958,76 @@ class BasicSwap(BaseApp, UIApp):
settings_copy["enabled_chart_coins"] = new_value
settings_changed = True
if "notifications_new_offers" in data:
new_value = data["notifications_new_offers"]
ensure(
isinstance(new_value, bool),
"New notifications_new_offers value not boolean",
)
if settings_copy.get("notifications_new_offers", False) != new_value:
settings_copy["notifications_new_offers"] = new_value
settings_changed = True
if "notifications_new_bids" in data:
new_value = data["notifications_new_bids"]
ensure(
isinstance(new_value, bool),
"New notifications_new_bids value not boolean",
)
if settings_copy.get("notifications_new_bids", True) != new_value:
settings_copy["notifications_new_bids"] = new_value
settings_changed = True
if "notifications_bid_accepted" in data:
new_value = data["notifications_bid_accepted"]
ensure(
isinstance(new_value, bool),
"New notifications_bid_accepted value not boolean",
)
if settings_copy.get("notifications_bid_accepted", True) != new_value:
settings_copy["notifications_bid_accepted"] = new_value
settings_changed = True
if "notifications_balance_changes" in data:
new_value = data["notifications_balance_changes"]
ensure(
isinstance(new_value, bool),
"New notifications_balance_changes value not boolean",
)
if (
settings_copy.get("notifications_balance_changes", True)
!= new_value
):
settings_copy["notifications_balance_changes"] = new_value
settings_changed = True
if "notifications_outgoing_transactions" in data:
new_value = data["notifications_outgoing_transactions"]
ensure(
isinstance(new_value, bool),
"New notifications_outgoing_transactions value not boolean",
)
if (
settings_copy.get("notifications_outgoing_transactions", True)
!= new_value
):
settings_copy["notifications_outgoing_transactions"] = new_value
settings_changed = True
if "notifications_duration" in data:
new_value = data["notifications_duration"]
ensure(
isinstance(new_value, int),
"New notifications_duration value not integer",
)
ensure(
5 <= new_value <= 60,
"notifications_duration must be between 5 and 60 seconds",
)
if settings_copy.get("notifications_duration", 20) != new_value:
settings_copy["notifications_duration"] = new_value
settings_changed = True
if settings_changed:
settings_path = os.path.join(self.data_dir, cfg.CONFIG_FILENAME)
settings_path_new = settings_path + ".new"