fix(scanner): advance only the accounts a tick actually scanned

An account that registered while the initial full scan was still running
got marked caught-up without ever being scanned, then stayed at a 0
balance forever (and the backend synced-gate would trust that 0). The
advance step used a blanket UPDATE-by-height; on the first tick prev_tip
is 0, so it matched any account, including one registered mid-scan.
Advance only the caught_up set snapshotted before the scan; an account
not in it falls through to the backfill phase on a later tick. Found by
live-node validation against a funded wallet.
This commit is contained in:
jwinterm
2026-07-09 17:17:41 -04:00
parent 7854da0b23
commit f042077575
2 changed files with 15 additions and 18 deletions
+4 -16
View File
@@ -117,22 +117,10 @@ pub async fn list_accounts(pool: &AnyPool) -> Result<Vec<AccountRow>> {
.collect())
}
/// Advance the scan height of already-caught-up accounts (scan_height at/above
/// the previous tip) to the new tip after a forward scan pass.
pub async fn advance_caught_up_scan_heights(
pool: &AnyPool,
to_height: i64,
from_height: i64,
) -> Result<()> {
sqlx::query("UPDATE accounts SET scan_height = $1 WHERE scan_height >= $2")
.bind(to_height)
.bind(from_height)
.execute(pool)
.await?;
Ok(())
}
/// Set one account's scan height (used after its backfill completes).
/// Set one account's scan height (after a forward-scan pass covers it, or after
/// its backfill completes). Advancing per-account (rather than a blanket
/// UPDATE-by-height) is deliberate: it must never mark an account that was not
/// actually scanned as caught-up.
pub async fn set_account_scan_height(pool: &AnyPool, rewind_hash: &str, height: i64) -> Result<()> {
sqlx::query("UPDATE accounts SET scan_height = $1 WHERE rewind_hash = $2")
.bind(height)
+11 -2
View File
@@ -100,10 +100,19 @@ async fn tick(pool: &AnyPool, node: &GrinNode) -> crate::error::Result<()> {
// ── Phase 2: spend reconcile (by absence from the unspent set) ──────────
reconcile_spends(pool, node, tip_height).await?;
// ── Phase 3: advance the cursor + caught-up accounts ────────────────────
// ── Phase 3: advance the cursor + the accounts we actually scanned ──────
// (Only after discovery AND reconcile succeeded — Risk R1.)
db::set_cursor(pool, new_mmr_index as i64, tip_height as i64, &tip_hash).await?;
db::advance_caught_up_scan_heights(pool, tip_height as i64, prev_tip as i64).await?;
// Advance ONLY the accounts that were in this tick's `caught_up` set — i.e.
// the ones the forward scan above actually covered. A blanket
// UPDATE-by-height would also mark an account that REGISTERED DURING this
// tick's scan (so it was never in `caught_up`, never scanned) as caught-up,
// leaving it stuck at a 0 balance forever — and the backend's synced gate
// would then trust that 0. Such an account instead stays "behind" and is
// picked up by the backfill phase on a later tick.
for rh in &caught_up {
db::set_account_scan_height(pool, rh, tip_height as i64).await?;
}
// ── Phase 4: backfill one pending new account (birthday → cursor) ───────
if let Some(acct) = accounts.iter().find(|a| a.scan_height < prev_tip) {