mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-12 01:38:54 +00:00
Goblin: apply node selection to the running wallet, no silent zero
Selecting a node in Settings > Wallet > Node moved the checkmark and persisted the choice, but the live session never switched: the node client is baked into the wallet instance at open time and was only rebuilt on the next unlock, so a running wallet kept polling the old (often dead) node. Owners onboarded on an older build still carry the retired grinnode.live default in connections.toml, which now 502s, so the wallet showed a stuck zero balance with no way to recover short of a force-restart. GRIM's own connection-settings UI reconnects on change (close + reopen); the Goblin node picker only called update_connection, so this was a Goblin regression, not inherited upstream. - Wallet::reconnect_node swaps the live node client's URL + secret in place via w2n_client (no close/reopen, so no password re-entry), updates the runtime connection so the UI reflects the switch at once, clears the stale sync error, and wakes the sync thread to refresh against the new node. Wired into all three picker actions. - node_url_secret factored out of create_node_client so open and live reconnect always resolve the same node for a given config. - balance_hero no longer renders a bare 0 during a node outage: a pure balance_subline state machine picks updating / can't-reach-node / last-known-balance, so an unreachable node reads honestly instead of looking like an emptied wallet. New goblin.home.balance_stale string in all six locales (drift green). - Unit tests for the balance state machine.
This commit is contained in:
@@ -366,6 +366,7 @@ goblin:
|
||||
node_synced: "Node synchronisiert"
|
||||
syncing: "Synchronisiere…"
|
||||
balance_updating: "Guthaben wird aktualisiert…"
|
||||
balance_stale: "Knoten nicht erreichbar · letzter bekannter Saldo"
|
||||
listening: "Wartet auf Zahlungen"
|
||||
block: "Block %{height}"
|
||||
waiting_for_chain: "Warte auf Chain…"
|
||||
|
||||
@@ -366,6 +366,7 @@ goblin:
|
||||
node_synced: "Node synced"
|
||||
syncing: "Syncing…"
|
||||
balance_updating: "Balance updating…"
|
||||
balance_stale: "Can't reach node · last known balance"
|
||||
listening: "Listening for payments"
|
||||
block: "Block %{height}"
|
||||
waiting_for_chain: "Waiting for chain…"
|
||||
|
||||
@@ -366,6 +366,7 @@ goblin:
|
||||
node_synced: "Nœud synchronisé"
|
||||
syncing: "Synchronisation…"
|
||||
balance_updating: "Solde en cours de mise à jour…"
|
||||
balance_stale: "Nœud injoignable · dernier solde connu"
|
||||
listening: "En attente de paiements"
|
||||
block: "Bloc %{height}"
|
||||
waiting_for_chain: "En attente de la chaîne…"
|
||||
|
||||
@@ -366,6 +366,7 @@ goblin:
|
||||
node_synced: "Узел синхронизирован"
|
||||
syncing: "Синхронизация…"
|
||||
balance_updating: "Баланс обновляется…"
|
||||
balance_stale: "Узел недоступен · последний известный баланс"
|
||||
listening: "Ожидание платежей"
|
||||
block: "Блок %{height}"
|
||||
waiting_for_chain: "Ожидание цепочки…"
|
||||
|
||||
@@ -366,6 +366,7 @@ goblin:
|
||||
node_synced: "Düğüm eşitlendi"
|
||||
syncing: "Eşitleniyor…"
|
||||
balance_updating: "Bakiye güncelleniyor…"
|
||||
balance_stale: "Düğüme ulaşılamıyor · son bilinen bakiye"
|
||||
listening: "Ödemeler bekleniyor"
|
||||
block: "Blok %{height}"
|
||||
waiting_for_chain: "Zincir bekleniyor…"
|
||||
|
||||
@@ -366,6 +366,7 @@ goblin:
|
||||
node_synced: "节点已同步"
|
||||
syncing: "同步中…"
|
||||
balance_updating: "余额更新中…"
|
||||
balance_stale: "无法连接节点 · 上次已知余额"
|
||||
listening: "正在监听付款"
|
||||
block: "区块 %{height}"
|
||||
waiting_for_chain: "等待链数据…"
|
||||
|
||||
@@ -1024,11 +1024,15 @@ impl GoblinWalletView {
|
||||
.map(|d| d.info.amount_locked + d.info.amount_awaiting_finalization)
|
||||
.unwrap_or(0);
|
||||
let updating = total == 0 && (in_flight > 0 || wallet.syncing());
|
||||
// Distinguish "still updating" from "can't reach the node" so a
|
||||
// node outage never renders as a silent zero (see balance_hero).
|
||||
let error = wallet.sync_error();
|
||||
w::balance_hero(
|
||||
ui,
|
||||
total,
|
||||
spendable,
|
||||
updating,
|
||||
error,
|
||||
wallet.info_sync_progress(),
|
||||
fiat_line(&data).as_deref(),
|
||||
56.0,
|
||||
@@ -3430,6 +3434,8 @@ impl GoblinWalletView {
|
||||
ui.add_space(10.0);
|
||||
if !active && row.response.interact(Sense::click()).clicked() {
|
||||
wallet.update_connection(&ConnectionMethod::Integrated);
|
||||
// Apply to the running session now, not on next unlock.
|
||||
wallet.reconnect_node();
|
||||
}
|
||||
}
|
||||
for conn in ConnectionsConfig::ext_conn_list() {
|
||||
@@ -3472,6 +3478,8 @@ impl GoblinWalletView {
|
||||
conn.id,
|
||||
conn.url.clone(),
|
||||
));
|
||||
// Apply to the running session now, not on next unlock.
|
||||
wallet.reconnect_node();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -3513,9 +3521,11 @@ impl GoblinWalletView {
|
||||
}
|
||||
};
|
||||
let conn = ExternalConnection::new(url, None, secret);
|
||||
ConnectionsConfig::add_ext_conn(conn.clone());
|
||||
wallet
|
||||
.update_connection(&ConnectionMethod::External(conn.id, conn.url.clone()));
|
||||
ConnectionsConfig::add_ext_conn(conn);
|
||||
// Apply to the running session now, not on next unlock.
|
||||
wallet.reconnect_node();
|
||||
self.node_url_input.clear();
|
||||
self.node_secret_input.clear();
|
||||
}
|
||||
|
||||
+117
-18
@@ -464,11 +464,45 @@ pub fn field_well(ui: &mut Ui, content: impl FnOnce(&mut Ui)) {
|
||||
/// A balance hero block: kicker, big number + ツ, optional fiat line.
|
||||
/// `updating` marks a zero balance that is only zero because funds are in
|
||||
/// flight or the first sync is still running.
|
||||
/// Honest subline shown under the balance figure. A wallet that can't reach a
|
||||
/// node must never present a bare `0` (or a silently-stale number) as if it were
|
||||
/// a live, confirmed balance.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum BalanceSubline {
|
||||
/// Nothing to add: the shown balance is live and non-zero.
|
||||
None,
|
||||
/// Balance reads 0 while a sync/first-scan is in progress or funds are in
|
||||
/// flight — say "updating", not "empty".
|
||||
Updating,
|
||||
/// Balance reads 0 and the node is unreachable with nothing cached — say
|
||||
/// "can't reach node", never a bare 0.
|
||||
Unreachable,
|
||||
/// A cached (last-known) balance is shown but the node is currently
|
||||
/// unreachable — flag it as possibly stale.
|
||||
Stale,
|
||||
}
|
||||
|
||||
/// Pure decision for the balance subline. `updating` means a sync is in progress
|
||||
/// (or funds are in flight); `error` means the wallet currently can't reach a
|
||||
/// node. Priority: updating > unreachable > stale.
|
||||
pub fn balance_subline(total: u64, updating: bool, error: bool) -> BalanceSubline {
|
||||
if total == 0 && updating {
|
||||
BalanceSubline::Updating
|
||||
} else if total == 0 && error {
|
||||
BalanceSubline::Unreachable
|
||||
} else if error {
|
||||
BalanceSubline::Stale
|
||||
} else {
|
||||
BalanceSubline::None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn balance_hero(
|
||||
ui: &mut Ui,
|
||||
total: u64,
|
||||
spendable: u64,
|
||||
updating: bool,
|
||||
error: bool,
|
||||
sync_pct: u8,
|
||||
fiat: Option<&str>,
|
||||
size: f32,
|
||||
@@ -498,24 +532,51 @@ pub fn balance_hero(
|
||||
);
|
||||
});
|
||||
}
|
||||
// A fresh sync or funds in flight leave a stark 0 that reads as "funds
|
||||
// vanished" — say the balance is still updating, with the scan % when the
|
||||
// node reports one (1..99), so the user sees progress without opening the
|
||||
// wallet switcher.
|
||||
if total == 0 && updating {
|
||||
let label = if (1..100).contains(&sync_pct) {
|
||||
format!("{} {sync_pct}%", t!("goblin.home.balance_updating"))
|
||||
} else {
|
||||
t!("goblin.home.balance_updating").to_string()
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::new(12.5, fonts::medium()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
// A stark 0 (or a stale number) reads as "funds vanished". Pick the honest
|
||||
// subline: still-updating, node-unreachable, or last-known-balance. See
|
||||
// [`balance_subline`] for the pure state machine.
|
||||
match balance_subline(total, updating, error) {
|
||||
BalanceSubline::Updating => {
|
||||
let label = if (1..100).contains(&sync_pct) {
|
||||
format!("{} {sync_pct}%", t!("goblin.home.balance_updating"))
|
||||
} else {
|
||||
t!("goblin.home.balance_updating").to_string()
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::new(12.5, fonts::medium()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
}
|
||||
BalanceSubline::Unreachable => {
|
||||
// Node unreachable and nothing cached yet: a bare 0 would claim the
|
||||
// wallet is empty. Say the truth so the user switches nodes instead
|
||||
// of assuming funds vanished.
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.home.cant_reach_node"))
|
||||
.font(FontId::new(12.5, fonts::medium()))
|
||||
.color(t.neg),
|
||||
);
|
||||
});
|
||||
}
|
||||
BalanceSubline::Stale => {
|
||||
// A cached balance is shown but we can't currently reach a node:
|
||||
// flag it as possibly stale rather than presenting it as live.
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.home.balance_stale"))
|
||||
.font(FontId::new(12.5, fonts::medium()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
}
|
||||
BalanceSubline::None => {}
|
||||
}
|
||||
if let Some(fiat) = fiat {
|
||||
ui.add_space(4.0);
|
||||
@@ -938,3 +999,41 @@ impl HoldToSend {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BalanceSubline, balance_subline};
|
||||
|
||||
// A live, non-zero balance needs no subline.
|
||||
#[test]
|
||||
fn live_balance_has_no_subline() {
|
||||
assert_eq!(balance_subline(1_000, false, false), BalanceSubline::None);
|
||||
}
|
||||
|
||||
// Zero while syncing / funds in flight is "updating", not "empty".
|
||||
#[test]
|
||||
fn zero_while_updating_says_updating() {
|
||||
assert_eq!(balance_subline(0, true, false), BalanceSubline::Updating);
|
||||
}
|
||||
|
||||
// Zero with an unreachable node and nothing cached must say so, never a
|
||||
// bare 0 that reads as "wallet empty" (the silent-zero incident).
|
||||
#[test]
|
||||
fn zero_with_node_error_says_unreachable() {
|
||||
assert_eq!(balance_subline(0, false, true), BalanceSubline::Unreachable);
|
||||
}
|
||||
|
||||
// A cached balance shown during a node outage is flagged stale, not passed
|
||||
// off as a live figure.
|
||||
#[test]
|
||||
fn cached_balance_with_error_is_stale() {
|
||||
assert_eq!(balance_subline(500, false, true), BalanceSubline::Stale);
|
||||
}
|
||||
|
||||
// Updating wins over error while the balance is still zero: a fresh switch
|
||||
// to a new node shows progress, not a scary red banner, until it errors.
|
||||
#[test]
|
||||
fn updating_takes_priority_over_error_at_zero() {
|
||||
assert_eq!(balance_subline(0, true, true), BalanceSubline::Updating);
|
||||
}
|
||||
}
|
||||
|
||||
+63
-4
@@ -250,14 +250,17 @@ impl Wallet {
|
||||
None
|
||||
}
|
||||
|
||||
/// Create [`HTTPNodeClient`] from provided config.
|
||||
fn create_node_client(config: &WalletConfig) -> Result<HTTPNodeClient, Error> {
|
||||
/// Resolve the node API URL and secret for the connection saved in `config`.
|
||||
/// Shared by [`Wallet::create_node_client`] (wallet open) and
|
||||
/// [`Wallet::reconnect_node`] (live node switch) so both always target the
|
||||
/// same node for a given config.
|
||||
fn node_url_secret(config: &WalletConfig) -> (String, Option<String>) {
|
||||
let integrated = || {
|
||||
let api_url = format!("http://{}", NodeConfig::get_api_address());
|
||||
let api_secret = NodeConfig::get_api_secret(true);
|
||||
(api_url, api_secret)
|
||||
};
|
||||
let (node_api_url, node_secret) = if let Some(id) = config.ext_conn_id {
|
||||
if let Some(id) = config.ext_conn_id {
|
||||
if let Some(conn) = ConnectionsConfig::ext_conn(id) {
|
||||
(conn.url, conn.secret)
|
||||
} else {
|
||||
@@ -265,7 +268,12 @@ impl Wallet {
|
||||
}
|
||||
} else {
|
||||
integrated()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Create [`HTTPNodeClient`] from provided config.
|
||||
fn create_node_client(config: &WalletConfig) -> Result<HTTPNodeClient, Error> {
|
||||
let (node_api_url, node_secret) = Self::node_url_secret(config);
|
||||
let client = if AppConfig::use_proxy() {
|
||||
let socks = AppConfig::use_socks_proxy();
|
||||
let url = if socks {
|
||||
@@ -780,6 +788,57 @@ impl Wallet {
|
||||
w_config.save();
|
||||
}
|
||||
|
||||
/// Apply the saved connection to the RUNNING wallet session immediately.
|
||||
///
|
||||
/// A node selection persisted by [`Wallet::update_connection`] only reaches
|
||||
/// an open wallet on the next open, because the node client is baked into the
|
||||
/// wallet instance at open time. This swaps the live node client's URL and
|
||||
/// secret in place (no close/reopen, so the password is not needed again),
|
||||
/// updates the runtime connection so the UI reflects the switch at once, then
|
||||
/// wakes the sync thread to refresh the balance against the new node.
|
||||
///
|
||||
/// If the new node is unreachable the sync simply errors and the honest
|
||||
/// "can't reach node" state surfaces (with the last-known balance retained),
|
||||
/// so the user is never stranded on a silent zero.
|
||||
pub fn reconnect_node(&self) {
|
||||
// Reflect the switch in the runtime connection right away so the picker
|
||||
// and status cards stop showing the previous node.
|
||||
let conn = self.get_config().connection();
|
||||
{
|
||||
let mut w_conn = self.connection.write();
|
||||
*w_conn = conn.clone();
|
||||
}
|
||||
// Clear the stale sync error/progress so the surface shows the honest
|
||||
// "updating" state while the new node is contacted.
|
||||
self.set_sync_error(false);
|
||||
self.reset_sync_attempts();
|
||||
self.info_sync_progress.store(0, Ordering::Relaxed);
|
||||
|
||||
// Swap the live node client, then kick a fresh sync. Done on a worker
|
||||
// thread: locking the instance can briefly contend with an in-flight
|
||||
// sync, and this is called from the UI thread.
|
||||
let wallet = self.clone();
|
||||
thread::spawn(move || {
|
||||
let has_instance = { wallet.instance.read().is_some() };
|
||||
if wallet.is_open() && has_instance {
|
||||
let (url, secret) = Self::node_url_secret(&wallet.get_config());
|
||||
let instance = { wallet.instance.read().clone().unwrap() };
|
||||
let mut wallet_lock = instance.lock();
|
||||
if let Ok(lc) = wallet_lock.lc_provider() {
|
||||
if let Ok(backend) = lc.wallet_inst() {
|
||||
let client = backend.w2n_client();
|
||||
client.set_node_url(&url);
|
||||
client.set_node_api_secret(secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Resume node polling (may be paused on Android) and wake the sync
|
||||
// thread so the balance refreshes against the new node now.
|
||||
wallet.resume_node_polling();
|
||||
wallet.sync();
|
||||
});
|
||||
}
|
||||
|
||||
/// Get external connection URL applied to [`WalletInstance`]
|
||||
/// after wallet opening if sync is running or get it from config.
|
||||
pub fn get_current_connection(&self) -> ConnectionMethod {
|
||||
|
||||
Reference in New Issue
Block a user