Skip to content

Commit 72e4ae7

Browse files
committed
Harden resubmit to wait for full scan completion
Move post-batch rebroadcast to after ensure_complete_scan_state, and refuse a tip that advanced on lightwalletd but never landed in the DB. Builds on ValarDragon's #386 deferral of resubmit while scan ranges remain.
1 parent 34de7b7 commit 72e4ae7

1 file changed

Lines changed: 111 additions & 102 deletions

File tree

  • rust/src/wallet/sync_engine

rust/src/wallet/sync_engine/mod.rs

Lines changed: 111 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,23 @@ fn should_resubmit_after_scan(pending_scan_blocks: u64) -> bool {
150150
pending_scan_blocks == 0
151151
}
152152

153+
/// Safe tip for resubmit after a lightwalletd refresh.
154+
///
155+
/// Returns the DB tip, or `None` if lightwalletd advanced but
156+
/// `update_chain_tip` did not (raw fresh tip is not safe to use).
157+
fn authoritative_resubmit_tip(
158+
previous_tip_height: u64,
159+
current_tip_height: u64,
160+
fresh_tip_height: u32,
161+
) -> Option<u32> {
162+
let fresh = u64::from(fresh_tip_height);
163+
if fresh > previous_tip_height && current_tip_height < fresh {
164+
None
165+
} else {
166+
u32::try_from(current_tip_height).ok()
167+
}
168+
}
169+
153170
fn scannable_batch_end(
154171
base_batch_size: u32,
155172
start: BlockHeight,
@@ -1411,23 +1428,10 @@ async fn run_sync_impl(
14111428
.map_err(|e| SyncError::db(format!("suggest_scan_ranges before resubmit: {e}")))?,
14121429
);
14131430

1414-
// 2.5. Resubmit any unmined, unexpired wallet txs now that we
1415-
// know the current tip and have no pending scan ranges. Matches
1416-
// the first of the three resubmit call sites in
1417-
// zcash-android-wallet-sdk's `processNewBlocks` (line 551).
1418-
// Best-effort: failures are logged inside the helper and must not
1419-
// abort the sync.
1420-
//
1421-
// We reuse the same `client` instead of opening a fresh channel.
1422-
// When the wallet is behind, scanning must run first: a locally
1423-
// created transaction may already be mined in one of the pending
1424-
// blocks while its local `mined_height` is still `NULL`.
1425-
//
1426-
// Pre-flight cancel/mode check: `update_chain_tip` and
1427-
// `open_lwd_channel` can take a couple of seconds under a
1428-
// slow connection, which is long enough for the user to hit
1429-
// stop. Skip the whole pass in that case instead of sending
1430-
// one more round of broadcasts after the UI asked us to quit.
1431+
// 2.5. Startup resubmit of unmined, unexpired wallet txs.
1432+
// Only when there is no pending scan work: otherwise a tx already
1433+
// mined in an unscanned block still has `mined_height = NULL` and
1434+
// would be rebroadcast. Best-effort; failures must not abort sync.
14311435
if !allow_resubmit {
14321436
log::info!("[{}] sync: startup resubmit disabled", elapsed());
14331437
} else if !should_resubmit_after_scan(startup_pending_blocks) {
@@ -2066,67 +2070,23 @@ async fn run_sync_impl(
20662070
// Enhancement
20672071
run_enhancement(&mut client, &mut db, db_data_path, network).await?;
20682072

2069-
// Refresh the tip before deciding whether post-batch
2070-
// auto-resubmit is safe. This matches
2071-
// zcash-android-wallet-sdk's lines 593/701 call sites (end of
2072-
// verify batch / end of regular batch), with an additional
2073-
// ordering guard: resubmit only after all pending scan ranges
2074-
// are drained.
2075-
//
2076-
// We deliberately re-fetch the chain tip via
2077-
// `get_latest_block` before each pass instead of reusing
2078-
// `tip.height` captured once at the top of `run_sync_impl`.
2079-
// `get_resubmittable_txs` decides "still inside expiry
2080-
// window" with `expiry_height > current_height`; using the
2081-
// stale top-of-sync tip meant a long catch-up session
2082-
// (several thousand blocks) could keep rebroadcasting txs
2083-
// whose expiry had already passed against the real chain
2084-
// tip. Refreshing here is one extra unary gRPC per batch,
2085-
// which is cheap compared to the batch download itself and
2086-
// closes the "resubmit expired tx forever" regression
2087-
// caught by Codex 2nd-round review finding 2.
2088-
//
2089-
// Pre-flight guard matches the one at the startup resubmit
2090-
// call site — if cancel or mode-change landed during
2091-
// `run_enhancement` (which can spend a second or two on a
2092-
// transparent-address scan), bail before opening a single
2093-
// new `send_transaction` RPC. The helper also consults the
2094-
// same closure between candidates and before each retry so
2095-
// a cancel arriving mid-pass stops initiating further
2096-
// broadcasts.
2097-
//
2098-
// Best-effort: helper swallows per-tx failures, we ignore
2099-
// the return value, and if the tip refresh itself fails we
2100-
// log and skip the pass rather than falling back to the
2101-
// stale height (the whole point of the refresh is to avoid
2102-
// rebroadcasting against a stale expiry window).
2073+
// Refresh tip after each batch so new blocks enter the scan
2074+
// queue. Do not resubmit here — wait until scan (and any
2075+
// repair rescans) finish. Only bump current_tip_height when
2076+
// update_chain_tip succeeds.
21032077
if cancel.load(Ordering::Relaxed) || desired_mode.load(Ordering::SeqCst) != running_mode {
21042078
log::info!(
2105-
"[{}] sync: cancel/mode observed before post-batch resubmit, exiting",
2079+
"[{}] sync: cancel/mode observed before post-batch tip refresh, exiting",
21062080
elapsed(),
21072081
);
21082082
return Ok(());
21092083
}
2110-
let resubmit_tip_height = match get_latest_block(&mut client)
2084+
let tip_before_refresh = current_tip_height;
2085+
match get_latest_block(&mut client)
21112086
.await
21122087
.map(|tip| tip.height as u32)
21132088
{
21142089
Ok(fresh_tip_height) => {
2115-
// Promote the fresh tip to the authoritative value
2116-
// so progress events and the final completion event
2117-
// use the latest chain height, not the one from
2118-
// sync startup. Also update the DB so
2119-
// suggest_scan_ranges picks up any new blocks that
2120-
// appeared since the initial (or last periodic) tip
2121-
// fetch.
2122-
//
2123-
// IMPORTANT: update_chain_tip MUST succeed before
2124-
// we bump current_tip_height. If the DB write fails,
2125-
// suggest_scan_ranges still operates on the old tip
2126-
// and the loop may break with isComplete=true —
2127-
// bumping current_tip_height prematurely would make
2128-
// the completion event claim a height the wallet
2129-
// never actually scanned. (Codex 3rd-round finding.)
21302090
if (fresh_tip_height as u64) > current_tip_height {
21312091
let fresh_bh = BlockHeight::from_u32(fresh_tip_height);
21322092
match with_wallet_db_write_lock(
@@ -2145,16 +2105,24 @@ async fn run_sync_impl(
21452105
}
21462106
}
21472107
}
2148-
Some(fresh_tip_height)
2108+
if authoritative_resubmit_tip(
2109+
tip_before_refresh,
2110+
current_tip_height,
2111+
fresh_tip_height,
2112+
)
2113+
.is_none()
2114+
{
2115+
log::debug!(
2116+
"[{}] sync: fresh tip {fresh_tip_height} not in DB yet \
2117+
(still at {current_tip_height}); not safe for resubmit",
2118+
elapsed(),
2119+
);
2120+
}
21492121
}
21502122
Err(e) => {
2151-
log::warn!(
2152-
"[{}] sync: resubmit tip refresh failed, skipping pass: {e}",
2153-
elapsed(),
2154-
);
2155-
None
2123+
log::warn!("[{}] sync: post-batch tip refresh failed: {e}", elapsed(),);
21562124
}
2157-
};
2125+
}
21582126
if cancel.load(Ordering::Relaxed) || desired_mode.load(Ordering::SeqCst) != running_mode {
21592127
log::info!("[{}] sync: exiting after post-batch tip refresh", elapsed());
21602128
return Ok(());
@@ -2176,30 +2144,13 @@ async fn run_sync_impl(
21762144
as u64
21772145
})
21782146
.sum();
2179-
if allow_resubmit && should_resubmit_after_scan(remaining) {
2180-
if let Some(fresh_tip_height) = resubmit_tip_height {
2181-
let _ = crate::wallet::sync::resubmit_pending_transactions(
2182-
db_data_path,
2183-
&mut client,
2184-
fresh_tip_height,
2185-
|| {
2186-
cancel.load(Ordering::Relaxed)
2187-
|| desired_mode.load(Ordering::SeqCst) != running_mode
2188-
},
2189-
)
2190-
.await;
2191-
}
2192-
} else if allow_resubmit {
2147+
if allow_resubmit && !should_resubmit_after_scan(remaining) {
21932148
log::debug!(
2194-
"[{}] sync: deferring post-batch resubmit until {} pending block(s) are scanned",
2149+
"[{}] sync: deferring resubmit; {} pending block(s) remain",
21952150
elapsed(),
21962151
remaining,
21972152
);
21982153
}
2199-
if cancel.load(Ordering::Relaxed) || desired_mode.load(Ordering::SeqCst) != running_mode {
2200-
log::info!("[{}] sync: exiting after resubmit pass", elapsed());
2201-
return Ok(());
2202-
}
22032154
// Adjust initial_total if new ranges appeared (e.g. new account added mid-sync).
22042155
// Use scanned + remaining as the true total, so progress never goes backward.
22052156
let scanned_so_far = initial_total.saturating_sub(prev_remaining);
@@ -2297,6 +2248,42 @@ async fn run_sync_impl(
22972248

22982249
let (final_scanned_height, final_tip_height) =
22992250
ensure_complete_scan_state(&db, current_tip_height)?;
2251+
2252+
// Resubmit unmined txs only after scan is complete, using the DB
2253+
// tip. Doing this earlier can rebroadcast txs that are already
2254+
// mined but not yet recorded locally.
2255+
if allow_resubmit {
2256+
if cancel.load(Ordering::Relaxed) || desired_mode.load(Ordering::SeqCst) != running_mode {
2257+
log::info!(
2258+
"[{}] sync: cancel/mode observed before caught-up resubmit, skipping",
2259+
elapsed(),
2260+
);
2261+
} else {
2262+
let fresh_for_gate = u32::try_from(final_tip_height).unwrap_or(u32::MAX);
2263+
match authoritative_resubmit_tip(final_tip_height, final_tip_height, fresh_for_gate) {
2264+
Some(resubmit_tip) => {
2265+
let _ = crate::wallet::sync::resubmit_pending_transactions(
2266+
db_data_path,
2267+
&mut client,
2268+
resubmit_tip,
2269+
|| {
2270+
cancel.load(Ordering::Relaxed)
2271+
|| desired_mode.load(Ordering::SeqCst) != running_mode
2272+
},
2273+
)
2274+
.await;
2275+
}
2276+
None => {
2277+
log::warn!(
2278+
"[{}] sync: skipping caught-up resubmit; tip {final_tip_height} \
2279+
is not a safe authoritative height",
2280+
elapsed(),
2281+
);
2282+
}
2283+
}
2284+
}
2285+
}
2286+
23002287
// Reconcile migration chain state only after the scan queue is fully
23012288
// drained, then update generic wallet locks for denomination outputs that
23022289
// became visible in this run. This is intentionally repeated after every
@@ -2532,14 +2519,7 @@ mod tests {
25322519

25332520
#[test]
25342521
fn resubmit_waits_until_fresh_tip_is_scanned() {
2535-
// This is the ordering observed in the live wallet:
2536-
//
2537-
// 1. Enhancement checks transaction status against the previous tip.
2538-
// 2. The post-batch refresh discovers the next block.
2539-
// 3. The transaction is resubmitted before that block is scanned.
2540-
//
2541-
// A transaction mined in block 101 therefore still has a local
2542-
// `mined_height` of `NULL` when the resubmit query runs.
2522+
// Tip advanced one block that has not been scanned yet.
25432523
assert!(!should_resubmit_after_scan(1));
25442524
}
25452525

@@ -2550,6 +2530,35 @@ mod tests {
25502530
));
25512531
}
25522532

2533+
#[test]
2534+
fn authoritative_resubmit_tip_uses_db_tip_when_fresh_does_not_advance() {
2535+
assert_eq!(authoritative_resubmit_tip(100, 100, 100), Some(100));
2536+
assert_eq!(authoritative_resubmit_tip(100, 100, 99), Some(100));
2537+
}
2538+
2539+
#[test]
2540+
fn authoritative_resubmit_tip_allows_advanced_tip_after_db_accept() {
2541+
assert_eq!(authoritative_resubmit_tip(100, 101, 101), Some(101));
2542+
}
2543+
2544+
#[test]
2545+
fn authoritative_resubmit_tip_refuses_advanced_tip_when_db_update_fails() {
2546+
// Fresh tip advanced; DB tip did not — unsafe for resubmit.
2547+
assert_eq!(authoritative_resubmit_tip(100, 100, 101), None);
2548+
}
2549+
2550+
#[test]
2551+
fn authoritative_resubmit_tip_never_returns_raw_fresh_ahead_of_db() {
2552+
assert_eq!(authoritative_resubmit_tip(100, 100, 105), None);
2553+
assert_eq!(authoritative_resubmit_tip(100, 104, 105), None);
2554+
}
2555+
2556+
#[test]
2557+
fn caught_up_resubmit_waits_for_zero_pending_including_repair_rescans() {
2558+
assert!(should_resubmit_after_scan(0));
2559+
assert!(!should_resubmit_after_scan(96));
2560+
}
2561+
25532562
#[test]
25542563
fn migration_anchor_retention_caps_batches_below_the_checkpoint_pruning_depth() {
25552564
assert_eq!(migration_anchor_retention_batch_size(1_000, true), 96);

0 commit comments

Comments
 (0)