@@ -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+
153170fn 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