From 81148945c075deeb18f01370ad325d7988a8f377 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 10 Jul 2026 07:29:57 -0400 Subject: [PATCH] fix(lws): make import_account idempotent for an existing account Re-registering an XMR/WOW account carrying the fixed wallet birthday triggered a backwards rescan (`if start_height < current { rescan }`), resetting the scan cursor to the birthday. Because the wallet re-registers before every balance read, an old-birthday account was reset on each poll: it read a 0 balance, slowly re-backfilled, then got reset again and never caught up (users saw 0 XMR/WOW despite real funds). Only a NEWLY added account needs the backwards rescan (monero-lws add_account starts at the chain tip). An account that already exists has already been imported from its birthday, so re-registration is now a no-op here. Genuine re-restore to an earlier birthday uses the explicit admin rescan path. --- src/infra/lws/client.rs | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/infra/lws/client.rs b/src/infra/lws/client.rs index 338ead8..3333465 100644 --- a/src/infra/lws/client.rs +++ b/src/infra/lws/client.rs @@ -226,20 +226,30 @@ impl LwsClient { view_key: &str, start_height: u64, ) -> Result<(), AppError> { - // `add_account` errors on a duplicate, so only register a NEW account; - // an existing one keeps its view key. Either way, lower its scan height - // to the birthday with a backwards rescan when that strictly lowers it. - let current = match self.account_scan_height(address).await? { - Some(h) => h, + // Only a NEWLY added account needs the backwards rescan to its birthday + // (monero-lws `add_account` starts every account at the chain tip). An + // account that ALREADY exists has already been imported from its + // birthday, so re-registration MUST be an idempotent no-op here. + // + // Rescanning an existing account resets its scan cursor to the birthday, + // so a client that re-registers on every balance poll (passing the fixed + // wallet birthday) would perpetually wipe scan progress: the account + // reads a 0 balance, slowly re-backfills, then gets reset again on the + // next poll and never catches up. A genuine re-restore to an EARLIER + // birthday goes through the explicit admin `rescan` path, not this one. + match self.account_scan_height(address).await? { + // Already registered: never reset an existing account from here. + Some(_) => Ok(()), None => { self.admin_add_account(address, view_key).await?; - self.account_scan_height(address).await?.unwrap_or(u64::MAX) + let current = + self.account_scan_height(address).await?.unwrap_or(u64::MAX); + if start_height < current { + self.rescan(vec![address.to_string()], start_height).await?; + } + Ok(()) } - }; - if start_height < current { - self.rescan(vec![address.to_string()], start_height).await?; } - Ok(()) } async fn admin_add_account(&self, address: &str, view_key: &str) -> Result<(), AppError> {