From 7a1afec6ab56ab45b22585b303e8c4a3ffb17b25 Mon Sep 17 00:00:00 2001 From: amanusk Date: Sun, 28 Jun 2026 09:27:47 +0300 Subject: [PATCH 1/2] feat(address): on-demand backfill for Calls-tab block-range gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Calls tab showed recent calls and older cached calls with no indication of the unfetched block range between them — the Dune `starknet.calls` fetch caps at 500 and the pf event window at 200, so an active address revisited after a few days has a hidden hole. Mirror the Transactions tab's nonce-gap feature, keyed on block ranges: - Detect block-span holes wider than CALL_GAP_SPAN_BLOCKS between cached calls and render an interactive gap row. Enter backfills that window newest-first (lazy chunking) via Dune `query_contract_calls_windowed` (contracts) or the pf event window `FillGap` (accounts), merging the result so the gap shrinks from its newer edge. - Persist fully-scanned ranges (new `address_call_scanned_ranges` table) so a genuinely-sparse hole stays closed across re-navigation and restarts; ranges load before first paint. Gaps filled with real calls self-close via the existing call cache. Adds gap-aware Calls-list navigation that reuses the existing static gap-index helpers and the calls fetch/merge/persist plumbing, plus a shared block-interval coalescing helper (utils::merge_block_interval). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/actions.rs | 27 +- src/app/input.rs | 4 + src/app/mod.rs | 84 ++++++- src/app/views/address_info.rs | 451 ++++++++++++++++++++++++++++++++++ src/data/cache.rs | 119 +++++++++ src/data/mod.rs | 13 + src/network/address.rs | 160 ++++++++++++ src/network/event_window.rs | 4 +- src/network/mod.rs | 18 ++ src/ui/views/address_info.rs | 192 +++++++++------ src/utils.rs | 22 ++ 11 files changed, 1011 insertions(+), 83 deletions(-) diff --git a/src/app/actions.rs b/src/app/actions.rs index bfb2d4f..6ff0187 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1,7 +1,7 @@ use starknet::core::types::Felt; use crate::app::state::SourceStatus; -use crate::app::views::address_info::UnfilledGap; +use crate::app::views::address_info::{UnfilledCallGap, UnfilledGap}; use crate::data::pathfinder::ClassHashEntry; use crate::data::types::{ AddressTxSummary, ClassContractEntry, ClassDeclareInfo, ContractCallSummary, @@ -77,6 +77,15 @@ pub enum Action { known_txs: Vec, gap: UnfilledGap, }, + /// On-demand Calls-tab block-range gap fill: dispatched when the user + /// presses Enter on a call-gap row. Backfills the `[lo_block, hi_block]` + /// window (contract → Dune `starknet.calls`; account → pf event window) + /// and merges the result, shrinking the gap from its newer edge. + FillAddressCallGap { + address: Felt, + gap: UnfilledCallGap, + is_contract: bool, + }, /// Enrich WS-streamed call stubs (missing sender/function/fee/timestamp). EnrichAddressCalls { address: Felt, @@ -271,6 +280,22 @@ pub enum Action { address: Felt, calls: Vec, }, + /// An on-demand Calls-tab gap fill covered the whole `[lo_block, hi_block]` + /// range (the windowed query returned below its limit). The reducer records + /// the range as fully scanned so `detect_unfilled_call_gaps` stops + /// re-surfacing a genuinely-sparse hole as a fillable gap. + AddressCallRangeScanned { + address: Felt, + lo_block: u64, + hi_block: u64, + }, + /// Persisted fully-scanned call ranges for an address, loaded from cache at + /// address entry. Seeds `scanned_call_ranges` so a previously-closed + /// (genuinely-sparse) gap stays suppressed across re-navigation/restart. + AddressCallScannedRangesLoaded { + address: Felt, + ranges: Vec<(u64, u64)>, + }, /// Older blocks loaded (appended to block list). OlderBlocksLoaded(Vec), /// More address transactions loaded (appended to address tx list). diff --git a/src/app/input.rs b/src/app/input.rs index abeffed..c6e0337 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -782,6 +782,10 @@ fn handle_enter(app: &mut App) -> Option { return app.navigate_to(NavTarget::Transaction(hash)); } crate::app::AddressTab::Calls => { + if app.address.call_gap_selected.is_some() { + app.dispatch_address_call_gap_fill(); + return None; + } let hash = app.address.calls.selected_item()?.tx_hash; return app.navigate_to(NavTarget::Transaction(hash)); } diff --git a/src/app/mod.rs b/src/app/mod.rs index de10130..460bd38 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -540,7 +540,7 @@ impl App { self.maybe_enrich_visible_address_txs(); } AddressTab::Calls => { - self.address.calls.scroll_by(delta); + self.address.call_list_scroll_by(delta); self.maybe_fetch_more_address_txs(); } AddressTab::MetaTxs => { @@ -622,7 +622,7 @@ impl App { self.maybe_enrich_visible_address_txs(); } AddressTab::Calls => { - self.address.calls.next(); + self.address.call_list_next(); self.maybe_fetch_more_address_txs(); } AddressTab::MetaTxs => { @@ -658,7 +658,7 @@ impl App { self.address.tx_list_previous(); self.maybe_enrich_visible_address_txs(); } - AddressTab::Calls => self.address.calls.previous(), + AddressTab::Calls => self.address.call_list_previous(), AddressTab::MetaTxs => self.address.meta_txs.previous(), AddressTab::Events => self.address.events.previous(), AddressTab::ClassHistory => { @@ -685,7 +685,7 @@ impl App { self.address.tx_list_select_first(); self.maybe_enrich_visible_address_txs(); } - AddressTab::Calls => self.address.calls.select_first(), + AddressTab::Calls => self.address.call_list_select_first(), AddressTab::MetaTxs => self.address.meta_txs.select_first(), AddressTab::Events => self.address.events.select_first(), AddressTab::ClassHistory => { @@ -723,7 +723,7 @@ impl App { self.maybe_enrich_visible_address_txs(); } AddressTab::Calls => { - self.address.calls.select_last(); + self.address.call_list_select_last(); self.maybe_fetch_more_address_txs(); } AddressTab::MetaTxs => { @@ -829,6 +829,45 @@ impl App { true } + /// Dispatch an on-demand backfill for the currently selected call-gap row + /// in the address Calls tab. Returns `true` if a fill was sent. No-op if no + /// call gap is selected, its fill is already in flight, or there is no + /// current address. Mirrors `dispatch_address_gap_fill`. + pub fn dispatch_address_call_gap_fill(&mut self) -> bool { + let Some(address) = self.address.context else { + return false; + }; + let Some(sel_lo) = self.address.call_gap_selected else { + return false; + }; + let Some(gap) = self + .address + .call_gaps + .iter_mut() + .find(|g| g.lo_block == sel_lo) + else { + return false; + }; + if gap.fill_dispatched { + return false; + } + let gap_clone = gap.clone(); + gap.fill_dispatched = true; + let _ = self.action_tx.send(Action::FillAddressCallGap { + address, + gap: gap_clone, + is_contract: self.address.is_contract, + }); + true + } + + /// Re-detect Calls-tab block-range gaps after a calls merge. Unlike the + /// nonce path there is no auto-fill — a block-range hole doesn't prove + /// missing calls, so every call gap waits for an explicit Enter. + fn refresh_address_call_gaps(&mut self) { + self.address.refresh_unfilled_call_gaps(); + } + /// Refresh the address's nonce-gap list, then auto-dispatch fills for any /// "tiny" gaps (≤ `AUTO_FILL_MAX_MISSING` missing nonces). Tiny gaps /// shouldn't require user action — a 1-nonce hole in a sparse account is @@ -1437,6 +1476,10 @@ impl App { self.address.dune_has_more = true; self.address.rpc_has_more = true; } + + // Surface any block-range hole between the freshly-fetched + // recent window and older cached calls as a fillable gap. + self.refresh_address_call_gaps(); } // Set default tab based on contract type @@ -1518,6 +1561,7 @@ impl App { self.address.rpc_cursor_block = Some(oldest_block); self.address.dune_has_more = has_more; self.address.rpc_has_more = has_more; + self.refresh_address_call_gaps(); } self.address.fetching_more_txs = false; } @@ -2083,12 +2127,42 @@ impl App { { self.address.calls.select_first(); } + // A merge can both close gaps (new calls bridge a hole) and + // surface new ones (a fresh top-of-list batch lands above the + // cached set). `refresh_unfilled_call_gaps` preserves any + // in-flight `fill_dispatched` whose range is unchanged. + self.refresh_address_call_gaps(); // Persist the merged set so it survives restarts. let _ = self.action_tx.send(Action::PersistAddressCalls { address, calls: self.address.calls.items.clone(), }); } + Action::AddressCallRangeScanned { + address, + lo_block, + hi_block, + } => { + if self.address.context == Some(address) { + // The fill covered this whole range. Record it so a + // genuinely-sparse hole inside it stops resurfacing, then + // re-detect (this pass is the one that drops the gap). + self.address.note_scanned_call_range(lo_block, hi_block); + self.refresh_address_call_gaps(); + } + } + Action::AddressCallScannedRangesLoaded { address, ranges } => { + if self.address.context == Some(address) { + // Merge the persisted closed ranges into whatever's in + // memory (empty on fresh entry; superset-safe on refresh), + // then re-detect so any hole we already scanned-and-found- + // sparse stays suppressed from first paint. + for (lo, hi) in ranges { + self.address.note_scanned_call_range(lo, hi); + } + self.refresh_address_call_gaps(); + } + } Action::AddressBalancesLoaded { address, balances } => { if self.address.context == Some(address) { // Balances can beat the nonce/class_hash fetch to the UI — diff --git a/src/app/views/address_info.rs b/src/app/views/address_info.rs index 2c17f3c..57b7136 100644 --- a/src/app/views/address_info.rs +++ b/src/app/views/address_info.rs @@ -91,6 +91,36 @@ pub struct UnfilledGap { pub fill_dispatched: bool, } +/// Minimum block span between two consecutive contract calls before we treat +/// the hole as a fillable gap in the Calls tab. Unlike nonces (a missing nonce +/// *proves* a missing tx), a block-range hole between two calls does not prove +/// missing calls — a contract simply may not have been called for a while. So +/// this threshold is deliberately large: it surfaces the real "recent fetch vs +/// stale cache" hole (the Dune `CONTRACT_CALL_LIMIT`/event-window page caps +/// leave hundreds of thousands of blocks unscanned on revisit) without tripping +/// on ordinary quiet stretches. Tunable — ~10k blocks is a few hours on +/// mainnet. Filling a range that turns out empty is harmless: the gap just +/// closes once we've scanned it. +pub const CALL_GAP_SPAN_BLOCKS: u64 = 10_000; + +/// A detected, unfilled block-range hole in the Calls list, deferred for +/// on-demand filling. The Calls analogue of [`UnfilledGap`] — keyed on block +/// numbers instead of nonces because incoming calls have no per-address +/// sequence. Each gap renders as its own row between the bordering calls; the +/// user presses Enter on it to dispatch a windowed backfill of `lo..=hi`. +#[derive(Clone, Debug)] +pub struct UnfilledCallGap { + /// Block number of the call just *below* the gap (older edge, lower bound + /// for the backfill scan). Also the gap's stable identity across + /// re-detection passes. + pub lo_block: u64, + /// Block number of the call just *above* the gap (newer edge, upper bound + /// for the backfill scan). + pub hi_block: u64, + /// Whether a fill has already been dispatched for this gap. + pub fill_dispatched: bool, +} + /// All state related to the address info view. pub struct AddressInfoState { pub info: Option, @@ -154,6 +184,25 @@ pub struct AddressInfoState { /// whenever a gap is showing. Persisted across frames to keep the viewport /// offset stable. pub txs_render_state: ratatui::widgets::ListState, + /// Detected block-range holes in the Calls list that have NOT been filled. + /// The Calls-tab analogue of `unfilled_gaps`; each renders as its own row + /// between the bordering calls and the user presses Enter to backfill it. + pub call_gaps: Vec, + /// `Some(lo_block)` when the rendered call-gap row whose `lo_block` matches + /// is currently selected. Identified by `lo_block` (not vec index) so the + /// selection survives re-detection passes — mirrors `gap_selected`. + pub call_gap_selected: Option, + /// ListState fed to ratatui for the Calls tab. Indexed against the rendered + /// list (calls + optional gap rows), so it diverges from `calls.state` + /// whenever a gap is showing. Mirrors `txs_render_state`. + pub calls_render_state: ratatui::widgets::ListState, + /// Block ranges `[lo, hi]` we have *fully* scanned for calls via on-demand + /// gap fills (a windowed query that returned below its limit ⇒ the whole + /// range is covered). Unlike nonces, a block-range hole doesn't prove + /// missing calls, so once we've scanned a range and found it sparse we must + /// remember that — otherwise `detect_unfilled_call_gaps` would re-surface + /// the same quiet stretch as a gap forever. Merged intervals, kept small. + pub scanned_call_ranges: Vec<(u64, u64)>, /// Meta-transactions (SNIP-9 outside executions) where this address is the intender. pub meta_txs: StatefulList, /// Pagination flag for MetaTxs tab (prevent duplicate fetches). @@ -249,6 +298,10 @@ impl Default for AddressInfoState { unfilled_gaps: Vec::new(), gap_selected: None, txs_render_state: ratatui::widgets::ListState::default(), + call_gaps: Vec::new(), + call_gap_selected: None, + calls_render_state: ratatui::widgets::ListState::default(), + scanned_call_ranges: Vec::new(), meta_txs: StatefulList::new(), fetching_meta_txs: false, meta_tx_cursor_block: None, @@ -294,6 +347,10 @@ impl AddressInfoState { self.unfilled_gaps.clear(); self.gap_selected = None; self.txs_render_state = ratatui::widgets::ListState::default(); + self.call_gaps.clear(); + self.call_gap_selected = None; + self.calls_render_state = ratatui::widgets::ListState::default(); + self.scanned_call_ranges.clear(); self.meta_txs = StatefulList::new(); self.fetching_meta_txs = false; self.meta_tx_cursor_block = None; @@ -783,6 +840,203 @@ impl AddressInfoState { pub fn tx_list_scroll_by(&mut self, delta: i64) { self.tx_list_step(delta); } + + // ---- Calls-tab block-range gaps (mirror the nonce-gap machinery) ---- + + /// Scan the current calls list for block-range holes wider than + /// [`CALL_GAP_SPAN_BLOCKS`]. The Calls-tab analogue of + /// `detect_unfilled_gaps`, keyed on block numbers (incoming calls have no + /// per-address nonce sequence). Distinct block numbers are considered, so + /// several calls sharing a block never fabricate a zero-span gap. Sorted by + /// `lo_block` ascending for stable render/test order. + pub fn detect_unfilled_call_gaps(&self) -> Vec { + let mut blocks: Vec = self + .calls + .items + .iter() + .map(|c| c.block_number) + .filter(|b| *b > 0) + .collect(); + if blocks.len() < 2 { + return Vec::new(); + } + blocks.sort_unstable(); + blocks.dedup(); + + let mut out = Vec::new(); + for w in blocks.windows(2) { + let lo_block = w[0]; + let hi_block = w[1]; + if hi_block.saturating_sub(lo_block) <= CALL_GAP_SPAN_BLOCKS { + continue; + } + // Suppress ranges we've already fully scanned — the hole is real + // (no calls there), not an unfetched window, so don't nag the user. + if self + .scanned_call_ranges + .iter() + .any(|(rlo, rhi)| *rlo <= lo_block && hi_block <= *rhi) + { + continue; + } + out.push(UnfilledCallGap { + lo_block, + hi_block, + fill_dispatched: false, + }); + } + out + } + + /// Record a `[lo, hi]` block range as fully scanned for calls, merging it + /// into `scanned_call_ranges` (coalescing overlapping/adjacent intervals so + /// the list stays small). Called when an on-demand fill covered the whole + /// range (returned below its query limit). + pub fn note_scanned_call_range(&mut self, lo: u64, hi: u64) { + crate::utils::merge_block_interval(&mut self.scanned_call_ranges, lo, hi); + } + + /// Re-detect call gaps and store them. Mirrors `refresh_unfilled_gaps`: + /// `fill_dispatched` is preserved only for gaps whose `(lo_block, hi_block)` + /// is unchanged — a shifted `hi_block` is evidence the lazy fill landed (it + /// always shrinks the gap from the newer edge), so we clear the flag and let + /// the user press Enter again for the next chunk. Drops a stale selection. + pub fn refresh_unfilled_call_gaps(&mut self) { + let prev_inflight: HashMap = self + .call_gaps + .iter() + .filter(|g| g.fill_dispatched) + .map(|g| (g.lo_block, g.hi_block)) + .collect(); + let mut next = self.detect_unfilled_call_gaps(); + for g in next.iter_mut() { + if let Some(prev_hi) = prev_inflight.get(&g.lo_block) + && *prev_hi == g.hi_block + { + g.fill_dispatched = true; + } + } + self.call_gaps = next; + if let Some(sel) = self.call_gap_selected + && !self.call_gaps.iter().any(|g| g.lo_block == sel) + { + self.call_gap_selected = None; + } + } + + /// Render-order positions for the call-gap rows: `(call_idx, lo_block)` + /// sorted by `call_idx` ascending. The calls list is block-descending, so + /// each gap renders immediately above the first (topmost) call whose block + /// equals `lo_block`. Mirrors `gap_render_positions`. + pub fn call_gap_render_positions(&self) -> Vec<(usize, u64)> { + let mut out: Vec<(usize, u64)> = self + .call_gaps + .iter() + .filter_map(|g| { + self.calls + .items + .iter() + .position(|c| c.block_number == g.lo_block) + .map(|p| (p, g.lo_block)) + }) + .collect(); + out.sort_by_key(|(p, _)| *p); + out + } + + /// Currently-selected call gap, if a gap row is the active selection. + pub fn selected_call_gap(&self) -> Option<&UnfilledCallGap> { + let lo = self.call_gap_selected?; + self.call_gaps.iter().find(|g| g.lo_block == lo) + } + + fn call_rendered_len(&self, gaps: &[(usize, u64)]) -> usize { + self.calls.items.len() + gaps.len() + } + + fn call_current_rendered(&self, gaps: &[(usize, u64)]) -> usize { + if let Some(sel_lo) = self.call_gap_selected + && let Some(g_idx) = gaps.iter().position(|(_, lo)| *lo == sel_lo) + { + return Self::gap_rendered_idx(gaps, g_idx); + } + let c = self.calls.state.selected().unwrap_or(0); + Self::tx_pos_to_rendered(c, gaps) + } + + fn apply_call_rendered(&mut self, r: usize, gaps: &[(usize, u64)]) { + for (g_idx, (p, lo)) in gaps.iter().enumerate() { + if p + g_idx == r { + self.call_gap_selected = Some(*lo); + return; + } + } + self.call_gap_selected = None; + let c = Self::rendered_to_tx_pos(r, gaps); + self.calls.state.select(Some(c)); + } + + /// Move selection by `delta` rows in the rendered calls list, clamping on + /// the first gap row crossed. Mirrors `tx_list_step`. + fn call_list_step(&mut self, delta: i64) { + if self.calls.items.is_empty() || delta == 0 { + return; + } + let gaps = self.call_gap_render_positions(); + let rendered_max = self.call_rendered_len(&gaps).saturating_sub(1); + let cur = self.call_current_rendered(&gaps); + let target = ((cur as i64) + delta).clamp(0, rendered_max as i64) as usize; + let final_r = Self::first_gap_crossed(&gaps, cur, target).unwrap_or(target); + self.apply_call_rendered(final_r, &gaps); + } + + pub fn call_list_next(&mut self) { + self.call_list_step(1); + } + + pub fn call_list_previous(&mut self) { + self.call_list_step(-1); + } + + pub fn call_list_scroll_by(&mut self, delta: i64) { + self.call_list_step(delta); + } + + /// Jump toward the first row, clamping on the first gap row crossed. + pub fn call_list_select_first(&mut self) { + if self.calls.items.is_empty() { + return; + } + let gaps = self.call_gap_render_positions(); + let cur = self.call_current_rendered(&gaps); + let final_r = Self::first_gap_crossed(&gaps, cur, 0).unwrap_or(0); + self.apply_call_rendered(final_r, &gaps); + } + + /// Jump toward the last row, clamping on the first gap row crossed. + pub fn call_list_select_last(&mut self) { + if self.calls.items.is_empty() { + return; + } + let gaps = self.call_gap_render_positions(); + let rendered_max = self.call_rendered_len(&gaps).saturating_sub(1); + let cur = self.call_current_rendered(&gaps); + let final_r = Self::first_gap_crossed(&gaps, cur, rendered_max).unwrap_or(rendered_max); + self.apply_call_rendered(final_r, &gaps); + } + + /// Rendered (gap-aware) selection index for the Calls list. Mirrors + /// `tx_list_rendered_selected`. + pub fn call_list_rendered_selected(&self) -> Option { + let gaps = self.call_gap_render_positions(); + if let Some(sel_lo) = self.call_gap_selected + && let Some(g_idx) = gaps.iter().position(|(_, lo)| *lo == sel_lo) + { + return Some(Self::gap_rendered_idx(&gaps, g_idx)); + } + let c = self.calls.state.selected()?; + Some(Self::tx_pos_to_rendered(c, &gaps)) + } } /// Upgrade an existing tx summary with better data from an incoming one. @@ -1172,6 +1426,203 @@ mod tests { } } + fn call_at(block: u64) -> ContractCallSummary { + call_summary(Felt::from(0xC0FFEE_u64), block) + } + + fn call_state_with(calls: Vec) -> AddressInfoState { + let mut s = AddressInfoState::default(); + // Live merge keeps calls block-descending; mirror that here. + s.calls.items = calls; + s.calls + .items + .sort_by(|a, b| b.block_number.cmp(&a.block_number)); + s + } + + #[test] + fn no_call_gap_when_dense() { + let state = call_state_with(vec![call_at(1000), call_at(1010), call_at(1020)]); + assert!(state.detect_unfilled_call_gaps().is_empty()); + } + + #[test] + fn no_call_gap_for_zero_block_stubs() { + // WS stubs land with block 0 before enrichment — they must not anchor a + // spurious "0..N" gap. + let state = call_state_with(vec![call_at(0), call_at(1_000_000)]); + assert!(state.detect_unfilled_call_gaps().is_empty()); + } + + #[test] + fn detects_call_gap_by_block_span() { + // Recent batch (~1.2M) vs stale cache (~1.0M): a ~195k-block hole. + let state = call_state_with(vec![ + call_at(1_200_500), + call_at(1_200_490), + call_at(1_005_000), + call_at(1_004_990), + ]); + let gaps = state.detect_unfilled_call_gaps(); + assert_eq!(gaps.len(), 1); + assert_eq!(gaps[0].lo_block, 1_005_000); + assert_eq!(gaps[0].hi_block, 1_200_490); + assert!(!gaps[0].fill_dispatched); + } + + #[test] + fn call_gap_threshold_boundary() { + // Exactly at the threshold → not a gap. + let state = call_state_with(vec![call_at(1000), call_at(1000 + CALL_GAP_SPAN_BLOCKS)]); + assert!(state.detect_unfilled_call_gaps().is_empty()); + // One block over → a gap. + let state = call_state_with(vec![ + call_at(1000), + call_at(1000 + CALL_GAP_SPAN_BLOCKS + 1), + ]); + assert_eq!(state.detect_unfilled_call_gaps().len(), 1); + } + + #[test] + fn refresh_preserves_call_gap_dispatched_when_unchanged() { + let mut state = call_state_with(vec![call_at(1_005_000), call_at(1_200_490)]); + state.call_gaps = state.detect_unfilled_call_gaps(); + state.call_gaps[0].fill_dispatched = true; + state.refresh_unfilled_call_gaps(); + assert_eq!(state.call_gaps.len(), 1); + assert!(state.call_gaps[0].fill_dispatched); + } + + #[test] + fn refresh_clears_call_gap_dispatched_when_gap_shrinks() { + // A lazy fill landed: the chunk arrived from the top of the gap, + // shrinking it from the newer edge (same lo_block, lower hi_block). + // The dispatched flag should clear so the residual row is fillable. + let mut state = call_state_with(vec![call_at(1_005_000), call_at(1_200_490)]); + state.call_gaps = state.detect_unfilled_call_gaps(); + state.call_gaps[0].fill_dispatched = true; + // A row just below the old top edge (490 blocks down ≤ threshold), so + // the upper sub-range stays contiguous and only the wide lower hole + // remains — the gap shrank from its newer edge. + state.calls.items.push(call_at(1_200_000)); + state + .calls + .items + .sort_by(|a, b| b.block_number.cmp(&a.block_number)); + state.refresh_unfilled_call_gaps(); + assert_eq!(state.call_gaps.len(), 1); + assert_eq!(state.call_gaps[0].lo_block, 1_005_000); + assert_eq!(state.call_gaps[0].hi_block, 1_200_000); + assert!(!state.call_gaps[0].fill_dispatched); + } + + #[test] + fn refresh_drops_stale_call_gap_selection() { + let mut state = call_state_with(vec![call_at(1_005_000), call_at(1_200_490)]); + state.refresh_unfilled_call_gaps(); + state.call_gap_selected = Some(1_005_000); + // Backfill closes the gap with intervening blocks. + state.calls.items = (0..=20) + .map(|i| call_at(1_005_000 + i * (CALL_GAP_SPAN_BLOCKS / 2))) + .collect(); + state + .calls + .items + .sort_by(|a, b| b.block_number.cmp(&a.block_number)); + state.refresh_unfilled_call_gaps(); + assert!(state.call_gaps.is_empty()); + assert!(state.call_gap_selected.is_none()); + } + + #[test] + fn scanned_range_suppresses_call_gap() { + let mut state = call_state_with(vec![call_at(1_005_000), call_at(1_200_490)]); + assert_eq!(state.detect_unfilled_call_gaps().len(), 1); + // After fully scanning the range, the (genuinely sparse) hole must not + // resurface as a gap. + state.note_scanned_call_range(1_005_000, 1_200_490); + assert!(state.detect_unfilled_call_gaps().is_empty()); + } + + #[test] + fn note_scanned_call_range_merges_overlaps() { + let mut state = AddressInfoState::default(); + state.note_scanned_call_range(100, 200); + state.note_scanned_call_range(150, 300); // overlaps → merge + state.note_scanned_call_range(301, 400); // adjacent → merge + state.note_scanned_call_range(1000, 1100); // disjoint + assert_eq!(state.scanned_call_ranges, vec![(100, 400), (1000, 1100)]); + } + + /// Single deferred call gap; list is block-descending like the live render. + fn call_state_one_gap() -> AddressInfoState { + // Blocks 200000, 199000, 200, 190 — only the 200..199000 hole exceeds + // CALL_GAP_SPAN_BLOCKS, so exactly one gap (lo_block 200). + let mut state = call_state_with(vec![ + call_at(200_000), + call_at(199_000), + call_at(200), + call_at(190), + ]); + state.refresh_unfilled_call_gaps(); + assert_eq!(state.call_gaps.len(), 1); + assert_eq!(state.call_gaps[0].lo_block, 200); + state.calls.state.select(Some(0)); + state + } + + #[test] + fn call_next_lands_on_gap_when_crossing_forward() { + let mut state = call_state_one_gap(); + // Start at block 4990 (call idx 1), just above the gap. + state.calls.state.select(Some(1)); + state.call_list_next(); + assert_eq!(state.call_gap_selected, Some(200)); + assert_eq!(state.calls.state.selected(), Some(1)); + } + + #[test] + fn call_next_off_gap_lands_on_lo_block_call() { + let mut state = call_state_one_gap(); + state.call_gap_selected = Some(200); + state.call_list_next(); + assert_eq!(state.call_gap_selected, None); + assert_eq!(state.calls.state.selected(), Some(2)); + } + + #[test] + fn call_scroll_by_clamps_on_gap_forward() { + let mut state = call_state_one_gap(); + state.calls.state.select(Some(0)); + state.call_list_scroll_by(10); + assert_eq!(state.call_gap_selected, Some(200)); + } + + #[test] + fn call_rendered_selected_tracks_gap_and_call_state() { + let mut state = call_state_one_gap(); + // Call idx 0 (block 5000) → rendered 0. + state.calls.state.select(Some(0)); + assert_eq!(state.call_list_rendered_selected(), Some(0)); + // Call idx 2 (block 200) → rendered 3 (one gap row in front of it). + state.calls.state.select(Some(2)); + assert_eq!(state.call_list_rendered_selected(), Some(3)); + // Selecting the gap → rendered 2. + state.call_gap_selected = Some(200); + assert_eq!(state.call_list_rendered_selected(), Some(2)); + } + + #[test] + fn call_nav_is_noop_with_no_gap() { + let mut state = call_state_with(vec![call_at(1000), call_at(1010)]); + state.refresh_unfilled_call_gaps(); + assert!(state.call_gaps.is_empty()); + state.calls.state.select(Some(0)); + state.call_list_next(); + assert_eq!(state.call_gap_selected, None); + assert_eq!(state.calls.state.selected(), Some(1)); + } + /// Builds a synthetic call list with `n` rows. ~30% of senders are unique /// one-shots; the remaining 70% are drawn from a pool of 50 repeating /// senders (the "hot" set the color map should pick up). Hot-pool indices diff --git a/src/data/cache.rs b/src/data/cache.rs index 7b54fbe..f3931ba 100644 --- a/src/data/cache.rs +++ b/src/data/cache.rs @@ -140,6 +140,14 @@ impl CachingDataSource { PRIMARY KEY (address, call_index) ); CREATE INDEX IF NOT EXISTS idx_addr_calls ON address_calls(address); + CREATE TABLE IF NOT EXISTS address_call_scanned_ranges ( + address TEXT NOT NULL, + lo_block INTEGER NOT NULL, + hi_block INTEGER NOT NULL, + PRIMARY KEY (address, lo_block) + ); + CREATE INDEX IF NOT EXISTS idx_addr_call_scanned + ON address_call_scanned_ranges(address); CREATE TABLE IF NOT EXISTS address_activity ( address TEXT PRIMARY KEY, min_block INTEGER NOT NULL, @@ -1601,6 +1609,85 @@ impl DataSource for CachingDataSource { }); } + fn load_call_scanned_ranges(&self, address: &Felt) -> Vec<(u64, u64)> { + let db = match self.db.get() { + Ok(db) => db, + Err(_) => return Vec::new(), + }; + let addr_hex = format!("{:#x}", address); + let mut stmt = match db.prepare( + "SELECT lo_block, hi_block FROM address_call_scanned_ranges \ + WHERE address = ?1 ORDER BY lo_block", + ) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + let rows = match stmt.query_map(params![addr_hex], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + }) { + Ok(r) => r, + Err(_) => return Vec::new(), + }; + rows.filter_map(|r| r.ok()) + .map(|(lo, hi)| (lo as u64, hi as u64)) + .collect() + } + + fn add_call_scanned_range(&self, address: &Felt, lo: u64, hi: u64) { + let pool = self.db.clone(); + let addr_hex = format!("{:#x}", address); + Self::dispatch_write(move || { + if let Ok(mut db) = pool.get() { + let tx = match db.transaction() { + Ok(t) => t, + Err(e) => { + warn!(error = %e, "add_call_scanned_range: begin transaction failed"); + return; + } + }; + // Read existing ranges, coalesce the new interval in, rewrite — + // keeps the persisted set sorted and minimal (same shape as the + // in-memory `scanned_call_ranges`). + let mut ranges: Vec<(u64, u64)> = { + let mut stmt = match tx.prepare( + "SELECT lo_block, hi_block FROM address_call_scanned_ranges \ + WHERE address = ?1", + ) { + Ok(s) => s, + Err(e) => { + warn!(error = %e, "add_call_scanned_range: prepare failed"); + return; + } + }; + stmt.query_map(params![&addr_hex], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + }) + .map(|rs| { + rs.filter_map(|r| r.ok()) + .map(|(lo, hi)| (lo as u64, hi as u64)) + .collect::>() + }) + .unwrap_or_default() + }; + crate::utils::merge_block_interval(&mut ranges, lo, hi); + let _ = tx.execute( + "DELETE FROM address_call_scanned_ranges WHERE address = ?1", + params![&addr_hex], + ); + for (rlo, rhi) in &ranges { + let _ = tx.execute( + "INSERT OR REPLACE INTO address_call_scanned_ranges \ + (address, lo_block, hi_block) VALUES (?1, ?2, ?3)", + params![&addr_hex, *rlo as i64, *rhi as i64], + ); + } + if let Err(e) = tx.commit() { + warn!(error = %e, "add_call_scanned_range: commit failed"); + } + } + }); + } + fn load_cached_meta_txs(&self, address: &Felt) -> Vec { let db = match self.db.get() { Ok(db) => db, @@ -2480,6 +2567,38 @@ mod tests { ); } + #[test] + fn call_scanned_ranges_persist_and_coalesce() { + let (ds, _d) = new_cache(); + let addr = Felt::from_hex("0xca115").unwrap(); + + assert!(ds.load_call_scanned_ranges(&addr).is_empty()); + + ds.add_call_scanned_range(&addr, 1_000_000, 1_100_000); + assert_eq!( + ds.load_call_scanned_ranges(&addr), + vec![(1_000_000, 1_100_000)] + ); + + // Overlapping range coalesces into one interval. + ds.add_call_scanned_range(&addr, 1_050_000, 1_200_000); + assert_eq!( + ds.load_call_scanned_ranges(&addr), + vec![(1_000_000, 1_200_000)] + ); + + // Disjoint range stays separate, sorted ascending. + ds.add_call_scanned_range(&addr, 2_000_000, 2_100_000); + assert_eq!( + ds.load_call_scanned_ranges(&addr), + vec![(1_000_000, 1_200_000), (2_000_000, 2_100_000)] + ); + + // Other addresses are isolated. + let other = Felt::from_hex("0xbeef").unwrap(); + assert!(ds.load_call_scanned_ranges(&other).is_empty()); + } + #[test] fn search_progress_merges_ranges() { let (ds, _d) = new_cache(); diff --git a/src/data/mod.rs b/src/data/mod.rs index cc4340a..96d9c09 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -205,6 +205,19 @@ pub trait DataSource: Send + Sync { fn save_address_calls(&self, _address: &Felt, _calls: &[ContractCallSummary]) { // Default: no-op. CachingDataSource overrides. } + /// Load the block ranges `[lo, hi]` we have *fully* scanned for contract + /// calls on this address via on-demand gap fills. Used to suppress + /// re-surfacing a genuinely-sparse hole as a Calls-tab gap after we've + /// already scanned it and found no (or few) calls. Persisted so the + /// "closed" knowledge survives re-navigation and restarts. + fn load_call_scanned_ranges(&self, _address: &Felt) -> Vec<(u64, u64)> { + Vec::new() + } + /// Record `[lo, hi]` as a fully-scanned contract-call range, coalescing it + /// into any existing persisted ranges for the address. + fn add_call_scanned_range(&self, _address: &Felt, _lo: u64, _hi: u64) { + // Default: no-op. CachingDataSource overrides. + } /// Load cached meta-tx (outside-execution) summaries for an address where the /// address is the intender. fn load_cached_meta_txs(&self, _address: &Felt) -> Vec { diff --git a/src/network/address.rs b/src/network/address.rs index d906b0d..8552a7e 100644 --- a/src/network/address.rs +++ b/src/network/address.rs @@ -894,6 +894,18 @@ pub(super) async fn fetch_and_send_address_info( }); } + // Seed any previously-recorded fully-scanned call ranges so a closed + // (genuinely-sparse) Calls-tab gap stays suppressed on re-entry. Sent + // before the cached AddressInfoLoaded so its gap re-detect already sees + // them. + let scanned_call_ranges = ds.load_call_scanned_ranges(&address); + if !scanned_call_ranges.is_empty() { + let _ = tx.send(Action::AddressCallScannedRangesLoaded { + address, + ranges: scanned_call_ranges, + }); + } + // First paint. The fresh re-emit below merges its nonce/class_hash // in; tx_summaries / contract_calls dedupe by hash on second emit. let _ = tx.send(Action::AddressInfoLoaded { @@ -2429,6 +2441,154 @@ pub(super) async fn run_nonce_gap_fill( ); } +/// On-demand fill of a single Calls-tab block-range gap. +/// +/// Backfills the gap's `[lo_block, hi_block]` window newest-first and merges +/// the result via `AddressCallsMerged`, so the gap shrinks from its newer edge +/// (lazy chunking — repeated Enter walks the range). When the windowed query +/// returns below its limit the whole range is covered, so we also emit +/// `AddressCallRangeScanned` to stop a genuinely-sparse hole resurfacing. +/// +/// Contracts use Dune's trace-indexed `starknet.calls` (the Calls-tab +/// authority); accounts fall back to the pf event window (`FillGap`), whose +/// `tx_rows` project to the same call rows. +#[allow(clippy::too_many_arguments)] +pub(super) async fn run_call_gap_fill( + address: starknet::core::types::Felt, + gap: crate::app::views::address_info::UnfilledCallGap, + is_contract: bool, + ds: &Arc, + dune: &Option>, + pf: &Option>, + abi_reg: &Arc, + action_tx: &mpsc::UnboundedSender, +) { + const CALL_GAP_FILL_LIMIT: u32 = 500; + + let _guard = QueryGuard::new( + action_tx, + format!("callgap:{}", query_addr_prefix(&address)), + "Calls gap fill".to_string(), + ); + let _ = action_tx.send(Action::LoadingStatus(format!( + "Filling calls gap (blocks {}..{})...", + gap.lo_block, gap.hi_block + ))); + + info!( + address = %format!("{:#x}", address), + lo_block = gap.lo_block, + hi_block = gap.hi_block, + is_contract, + "Calls gap fill: backfilling block range on demand" + ); + + // (merged call rows, whether the whole range was covered in this page) + let (calls, fully_covered): (Vec, bool) = + if is_contract && let Some(dune_client) = dune { + // Date hint lets Dune prune `starknet.calls` partitions instead of + // scanning the whole table for the bounded block window. + let min_date = block_date_floor(ds, gap.hi_block).await; + match dune_client + .query_contract_calls_windowed( + address, + gap.lo_block, + gap.hi_block, + CALL_GAP_FILL_LIMIT, + min_date, + ) + .await + { + Ok(rows) => { + let covered = rows.len() < CALL_GAP_FILL_LIMIT as usize; + // Replace Dune's immediate caller with the real tx sender and + // fill fee/timestamp/function name (same path as the bulk + // Dune fetch). + let enriched = + enrich_dune_calls(address, rows, abi_reg, ds, pf.as_ref(), action_tx).await; + (enriched, covered) + } + Err(e) => { + warn!(addr = %format!("{:#x}", address), error = %e, "Calls gap fill: Dune query failed"); + let _ = action_tx.send(Action::LoadingStatus(String::new())); + return; + } + } + } else if let Some(pf_client) = pf { + // Account (or no Dune): scan the gap's event window and project the + // pf tx_rows to call rows. + let Some(latest_block) = ds.latest_block_hint().await else { + warn!(addr = %format!("{:#x}", address), "Calls gap fill: no chain head available"); + let _ = action_tx.send(Action::LoadingStatus(String::new())); + return; + }; + match crate::network::event_window::ensure_address_events_window( + address, + EventQueryKind::Account, + crate::network::event_window::EventWindowPolicy::FillGap { + from_block: gap.lo_block, + to_block: gap.hi_block, + }, + Some(pf_client), + ds, + latest_block, + gap.lo_block, + ) + .await + { + Ok(outcome) => { + let covered = outcome.next_token.is_none(); + let _ = action_tx.send(Action::AddressEventWindowUpdated { + address, + min_searched: outcome.min_searched, + max_searched: outcome.max_searched, + deferred_gap: outcome.deferred_gap, + }); + let calls = + build_contract_calls_from_pf_rows(address, &outcome.page.tx_rows, abi_reg) + .await; + (calls, covered) + } + Err(e) => { + warn!(addr = %format!("{:#x}", address), error = %e, "Calls gap fill: event-window scan failed"); + let _ = action_tx.send(Action::LoadingStatus(String::new())); + return; + } + } + } else { + debug!(addr = %format!("{:#x}", address), "Calls gap fill: no Dune/pf backend available"); + let _ = action_tx.send(Action::LoadingStatus(String::new())); + return; + }; + + info!( + address = %format!("{:#x}", address), + lo_block = gap.lo_block, + hi_block = gap.hi_block, + calls = calls.len(), + fully_covered, + "Calls gap fill: backend returned rows" + ); + + if !calls.is_empty() { + let _ = action_tx.send(Action::AddressCallsMerged { address, calls }); + } + if fully_covered { + // Persist the scanned range so the closed gap survives re-navigation + // and restarts, then notify the App for the in-memory update. The + // AddressCallRangeScanned action is sent AFTER the merge so the App's + // gap re-detect (triggered by the merge) is the one that suppresses + // the hole. + ds.add_call_scanned_range(&address, gap.lo_block, gap.hi_block); + let _ = action_tx.send(Action::AddressCallRangeScanned { + address, + lo_block: gap.lo_block, + hi_block: gap.hi_block, + }); + } + let _ = action_tx.send(Action::LoadingStatus(String::new())); +} + /// Fill only the *small* nonce gaps (≤50 blocks each) via RPC block scans. /// /// Large gaps are skipped here and deferred to on-demand fill via diff --git a/src/network/event_window.rs b/src/network/event_window.rs index 557a8e4..cbfec67 100644 --- a/src/network/event_window.rs +++ b/src/network/event_window.rs @@ -96,8 +96,8 @@ pub enum EventWindowPolicy { /// successive pages (e.g. doubling on empty hits up to /// [`EXTEND_DOWN_MAX_WINDOW`]). ExtendDown { window_size: u64 }, - /// Fetch a specific block range — used to fill a previously deferred gap. - #[allow(dead_code)] // wired with the gap UI task + /// Fetch a specific block range — used to fill a previously deferred gap + /// (Calls-tab on-demand gap fill, see `run_call_gap_fill`). FillGap { from_block: u64, to_block: u64 }, } diff --git a/src/network/mod.rs b/src/network/mod.rs index 9b6e127..2bb7ab9 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -409,6 +409,23 @@ pub async fn run_network_task( ) .await; } + Action::FillAddressCallGap { + address, + gap, + is_contract, + } => { + address::run_call_gap_fill( + address, + gap, + is_contract, + &ds, + &dune, + &pf, + &abi_reg, + &tx, + ) + .await; + } Action::EnrichAddressCalls { address, hashes_with_blocks, @@ -683,6 +700,7 @@ fn action_is_cancellable(action: &Action) -> bool { Action::EnrichAddressTxs { .. } | Action::EnrichAddressEndpoints { .. } | Action::FillAddressNonceGaps { .. } + | Action::FillAddressCallGap { .. } | Action::EnrichAddressCalls { .. } | Action::FetchAddressMetaTxs { .. } | Action::ClassifyPotentialMetaTx { .. } diff --git a/src/ui/views/address_info.rs b/src/ui/views/address_info.rs index a8f0664..350301a 100644 --- a/src/ui/views/address_info.rs +++ b/src/ui/views/address_info.rs @@ -789,84 +789,122 @@ fn draw_calls_tab(f: &mut Frame, app: &mut App, area: Rect) { .map(|(reg, info)| reg.is_privacy_address(&info.address)) .unwrap_or(false); - let items: Vec = app - .address - .calls - .items - .iter() - .map(|call| { - let sender_style = - known_or_palette_style(&call.sender, registry, &app.address.call_color_map); - let sender_label = app.format_address(&call.sender); - let sender_display = if sender_label.chars().count() > 25 { - let truncated: String = sender_label.chars().take(24).collect(); - format!("{truncated}…") - } else { - sender_label - }; - let func = if call.function_name.chars().count() > 30 { - let truncated: String = call.function_name.chars().take(29).collect(); - format!("{truncated}…") - } else { - call.function_name.clone() - }; - let fee_str = format_strk_u128(call.total_fee_fri) - .trim_end_matches(" STRK") - .to_string(); - let nonce_str = match call.nonce { - Some(n) => n.to_string(), - None => "—".to_string(), - }; - let tip_str = if call.tip > 0 { - format_fri(call.tip as u128) - } else { - "0".to_string() - }; - let status_style = match call.status.as_str() { - "OK" => theme::STATUS_OK, - "REV" => theme::STATUS_REVERTED, - _ => theme::SUGGESTION_STYLE, - }; + // (call_idx, lo_block) for each block-range gap, sorted by call_idx. Each + // gap renders as its own ListItem above the first call at `lo_block`. + let gap_positions = app.address.call_gap_render_positions(); + let mut next_gap = gap_positions.iter().peekable(); + let gap_info_for_lo = |lo: u64| -> Option<(u64, bool)> { + app.address + .call_gaps + .iter() + .find(|g| g.lo_block == lo) + .map(|g| (g.hi_block, g.fill_dispatched)) + }; - let tx_label = app.resolve_tx(&call.tx_hash); - let tx_hash_display = tx_hash_cell(tx_label, &call.tx_hash); - let tx_hash_style = if tx_label.is_some() { - theme::LABEL_STYLE - } else { - theme::TX_HASH_STYLE - }; + let mut items: Vec = + Vec::with_capacity(app.address.calls.items.len() + gap_positions.len()); + for (idx, call) in app.address.calls.items.iter().enumerate() { + while let Some(&&(p, lo)) = next_gap.peek() + && p == idx + { + if let Some((hi, dispatched)) = gap_info_for_lo(lo) { + let msg = if dispatched { + format!(" ── calls gap (blocks {lo}..{hi}) — loading / press r to retry ──") + } else { + format!(" ── calls gap (blocks {lo}..{hi}) — press Enter to load ──") + }; + items.push(ListItem::new(Line::from(Span::styled( + msg, + theme::SUGGESTION_STYLE, + )))); + } + next_gap.next(); + } - // Privacy iff the viewed contract is itself a privacy address - // (every incoming call is then a privacy interaction) OR any OE - // inner target is in the curated bundle (for non-privacy contract - // pages like AVNU Forwarder where the pool only appears as an - // inner call). - let is_privacy_call = viewed_is_privacy - || registry - .map(|reg| call.inner_targets.iter().any(|t| reg.is_privacy_address(t))) - .unwrap_or(false); - let prv_marker_text = if is_privacy_call { "🛡 " } else { " " }; + let sender_style = + known_or_palette_style(&call.sender, registry, &app.address.call_color_map); + let sender_label = app.format_address(&call.sender); + let sender_display = if sender_label.chars().count() > 25 { + let truncated: String = sender_label.chars().take(24).collect(); + format!("{truncated}…") + } else { + sender_label + }; + let func = if call.function_name.chars().count() > 30 { + let truncated: String = call.function_name.chars().take(29).collect(); + format!("{truncated}…") + } else { + call.function_name.clone() + }; + let fee_str = format_strk_u128(call.total_fee_fri) + .trim_end_matches(" STRK") + .to_string(); + let nonce_str = match call.nonce { + Some(n) => n.to_string(), + None => "—".to_string(), + }; + let tip_str = if call.tip > 0 { + format_fri(call.tip as u128) + } else { + "0".to_string() + }; + let status_style = match call.status.as_str() { + "OK" => theme::STATUS_OK, + "REV" => theme::STATUS_REVERTED, + _ => theme::SUGGESTION_STYLE, + }; - let line = Line::from(vec![ - Span::styled(format!(" {:<25} ", sender_display), sender_style), - Span::styled(format!("{:<31}", func), theme::LABEL_STYLE), - Span::styled(format!("{:<14}", tx_hash_display), tx_hash_style), - Span::styled(format!("{:<10}", nonce_str), theme::NORMAL_STYLE), - Span::styled(format!("{:<17}", fee_str), theme::TX_FEE_STYLE), - Span::styled(format!("{:<17}", tip_str), theme::SUGGESTION_STYLE), - Span::styled( - format!("#{:<9}", call.block_number), - theme::BLOCK_NUMBER_STYLE, - ), - Span::styled(format!("{:<4}", &call.status), status_style), - Span::styled(prv_marker_text, theme::PRIVACY_STYLE), - Span::styled(format_age(call.timestamp), theme::BLOCK_AGE_STYLE), - ]); - ListItem::new(line) - }) - .collect(); + let tx_label = app.resolve_tx(&call.tx_hash); + let tx_hash_display = tx_hash_cell(tx_label, &call.tx_hash); + let tx_hash_style = if tx_label.is_some() { + theme::LABEL_STYLE + } else { + theme::TX_HASH_STYLE + }; - let gap_suffix = event_window_gap_suffix(app); + // Privacy iff the viewed contract is itself a privacy address + // (every incoming call is then a privacy interaction) OR any OE + // inner target is in the curated bundle (for non-privacy contract + // pages like AVNU Forwarder where the pool only appears as an + // inner call). + let is_privacy_call = viewed_is_privacy + || registry + .map(|reg| call.inner_targets.iter().any(|t| reg.is_privacy_address(t))) + .unwrap_or(false); + let prv_marker_text = if is_privacy_call { "🛡 " } else { " " }; + + let line = Line::from(vec![ + Span::styled(format!(" {:<25} ", sender_display), sender_style), + Span::styled(format!("{:<31}", func), theme::LABEL_STYLE), + Span::styled(format!("{:<14}", tx_hash_display), tx_hash_style), + Span::styled(format!("{:<10}", nonce_str), theme::NORMAL_STYLE), + Span::styled(format!("{:<17}", fee_str), theme::TX_FEE_STYLE), + Span::styled(format!("{:<17}", tip_str), theme::SUGGESTION_STYLE), + Span::styled( + format!("#{:<9}", call.block_number), + theme::BLOCK_NUMBER_STYLE, + ), + Span::styled(format!("{:<4}", &call.status), status_style), + Span::styled(prv_marker_text, theme::PRIVACY_STYLE), + Span::styled(format_age(call.timestamp), theme::BLOCK_AGE_STYLE), + ]); + items.push(ListItem::new(line)); + } + + // Prefer the interactive block-range gap summary; fall back to the passive + // event-window deferred-gap hint when there are no fillable list gaps. + let gap_suffix = if app.address.call_gaps.is_empty() { + event_window_gap_suffix(app) + } else { + let n = app.address.call_gaps.len(); + let plural = if n == 1 { "" } else { "s" }; + let any_pending = app.address.call_gaps.iter().any(|g| !g.fill_dispatched); + if any_pending { + format!(" — {n} gap{plural} (Enter on a gap row to load) ") + } else { + format!(" — {n} gap{plural} (loading / press r to retry) ") + } + }; let count = call_count_fragment(app); let title = if app.is_loading { format!(" Calls ({count}) fetching...{gap_suffix} ") @@ -884,7 +922,11 @@ fn draw_calls_tab(f: &mut Frame, app: &mut App, area: Rect) { .highlight_style(theme::SELECTED_STYLE.add_modifier(Modifier::BOLD)) .highlight_symbol(">> "); - f.render_stateful_widget(list, list_area, &mut app.address.calls.state); + // Sync the gap-aware rendered selection into the persistent render state. + app.address + .calls_render_state + .select(app.address.call_list_rendered_selected()); + f.render_stateful_widget(list, list_area, &mut app.address.calls_render_state); } fn draw_meta_txs_tab(f: &mut Frame, app: &mut App, area: Rect) { diff --git a/src/utils.rs b/src/utils.rs index 0517cb7..cc2a0ac 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -11,3 +11,25 @@ pub fn felt_to_u128(felt: &Felt) -> u128 { let bytes = felt.to_bytes_be(); u128::from_be_bytes(bytes[16..32].try_into().unwrap_or([0u8; 16])) } + +/// Insert `[lo, hi]` into a set of sorted, non-overlapping block intervals, +/// coalescing any that overlap or merely touch (adjacency merges, so +/// `[100,200]` + `[201,300]` becomes `[100,300]`). Keeps `ranges` sorted and +/// minimal. Tolerates `lo > hi` by swapping. Shared between the in-memory +/// scanned-range tracker (`AddressInfoState::note_scanned_call_range`) and the +/// SQLite-backed persistence (`cache`'s `add_call_scanned_range`). +pub fn merge_block_interval(ranges: &mut Vec<(u64, u64)>, lo: u64, hi: u64) { + let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) }; + let mut merged = (lo, hi); + let mut rest = Vec::with_capacity(ranges.len() + 1); + for &(rlo, rhi) in ranges.iter() { + if rlo <= merged.1.saturating_add(1) && merged.0.saturating_sub(1) <= rhi { + merged = (merged.0.min(rlo), merged.1.max(rhi)); + } else { + rest.push((rlo, rhi)); + } + } + rest.push(merged); + rest.sort_unstable(); + *ranges = rest; +} From 347c089bc687279efe508b93fbccabdc511f2366 Mon Sep 17 00:00:00 2001 From: amanusk Date: Mon, 29 Jun 2026 09:08:22 +0300 Subject: [PATCH 2/2] fix(address): correct Calls-tab gap fill window, filter kind, and stuck-loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three issues in the on-demand Calls-tab gap fill flagged in review: - FillGap pf path ignored the upper bound: `fetch_address_activity` hardcoded `to_block = None`, so a `[lo, hi]` gap fill scanned `[lo, head]` newest-first and never converged on its range. Thread `to_block` through into `get_events_for_address`/`get_contract_events` (which already accept it). - Contract-without-Dune used the keyed `transaction_executed` (`Account`) filter, which targets an address as a tx sender — wrong for a contract. Pick the EventQueryKind from `is_contract`. - A failed/no-op fill left the gap row stuck on "loading": `r` (full refresh) preserves `fill_dispatched` for an unchanged range, so Enter could never re-dispatch. A drop guard now emits `AddressCallGapFillFinished` on every exit path of `run_call_gap_fill`; the reducer clears `fill_dispatched` so the row becomes re-dispatchable. Drop the misleading "press r to retry" text. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/actions.rs | 8 +++++ src/app/mod.rs | 16 +++++++++ src/network/address.rs | 67 +++++++++++++++++++++++++++++++----- src/network/event_window.rs | 29 +++++++++++----- src/ui/views/address_info.rs | 4 +-- 5 files changed, 104 insertions(+), 20 deletions(-) diff --git a/src/app/actions.rs b/src/app/actions.rs index 6ff0187..2b9f426 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -289,6 +289,14 @@ pub enum Action { lo_block: u64, hi_block: u64, }, + /// An on-demand Calls-tab gap fill finished (success, no-op, or error). The + /// reducer clears the matching gap's `fill_dispatched` so the row leaves the + /// "loading" state and Enter can re-dispatch — without this a failed fill + /// would leave the gap stuck forever. + AddressCallGapFillFinished { + address: Felt, + lo_block: u64, + }, /// Persisted fully-scanned call ranges for an address, loaded from cache at /// address entry. Seeds `scanned_call_ranges` so a previously-closed /// (genuinely-sparse) gap stays suppressed across re-navigation/restart. diff --git a/src/app/mod.rs b/src/app/mod.rs index 460bd38..4005273 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2151,6 +2151,22 @@ impl App { self.refresh_address_call_gaps(); } } + Action::AddressCallGapFillFinished { address, lo_block } => { + // The fill task ended (success, no-op, or error). Clear the + // in-flight flag so the row stops showing "loading" and Enter + // can re-dispatch. On a successful fill the gap may have already + // shrunk/closed via the merge above, in which case this is a + // harmless no-op. + if self.address.context == Some(address) + && let Some(gap) = self + .address + .call_gaps + .iter_mut() + .find(|g| g.lo_block == lo_block) + { + gap.fill_dispatched = false; + } + } Action::AddressCallScannedRangesLoaded { address, ranges } => { if self.address.context == Some(address) { // Merge the persisted closed ranges into whatever's in diff --git a/src/network/address.rs b/src/network/address.rs index 8552a7e..4de759c 100644 --- a/src/network/address.rs +++ b/src/network/address.rs @@ -301,11 +301,16 @@ pub(crate) struct AddressActivityPage { /// MetaTxIntenderSummary for the MetaTxs tab) are cheap CPU-bound passes over /// the returned page and live in their own helpers. /// +/// `to_block` is an inclusive upper bound (`None` ⇒ up to chain head). Bounded +/// windows are required for gap fills (`FillGap`) — without it pf scans from +/// `from_block` to the tip and a `[lo, hi]` fill never converges on its range. +/// /// pf-query-only by design — the RPC fallback keeps the legacy per-tx flow. pub(crate) async fn fetch_address_activity( address: starknet::core::types::Felt, kind: EventQueryKind, from_block: u64, + to_block: Option, continuation_token: Option, limit: u32, pf: &Arc, @@ -315,11 +320,18 @@ pub(crate) async fn fetch_address_activity( // 1. Events. let (events, next_token) = match kind { EventQueryKind::Account => pf - .get_events_for_address(address, from_block, None, limit, continuation_token) + .get_events_for_address(address, from_block, to_block, limit, continuation_token) .await .map_err(|e| crate::error::SnbeatError::Provider(e.to_string()))?, EventQueryKind::Contract => pf - .get_contract_events(address, from_block, None, &[], limit, continuation_token) + .get_contract_events( + address, + from_block, + to_block, + &[], + limit, + continuation_token, + ) .await .map_err(|e| crate::error::SnbeatError::Provider(e.to_string()))?, }; @@ -2441,6 +2453,26 @@ pub(super) async fn run_nonce_gap_fill( ); } +/// On drop, notifies the App that the call-gap fill for `lo_block` has +/// finished so the gap row's `fill_dispatched` flag is cleared and the row +/// becomes re-dispatchable (Enter). Fires on *every* exit of +/// `run_call_gap_fill` — including the error early-returns and a no-op page — +/// so a failed fill never leaves the row stuck showing "loading". +struct CallGapFillGuard { + action_tx: mpsc::UnboundedSender, + address: starknet::core::types::Felt, + lo_block: u64, +} + +impl Drop for CallGapFillGuard { + fn drop(&mut self) { + let _ = self.action_tx.send(Action::AddressCallGapFillFinished { + address: self.address, + lo_block: self.lo_block, + }); + } +} + /// On-demand fill of a single Calls-tab block-range gap. /// /// Backfills the gap's `[lo_block, hi_block]` window newest-first and merges @@ -2450,7 +2482,8 @@ pub(super) async fn run_nonce_gap_fill( /// `AddressCallRangeScanned` to stop a genuinely-sparse hole resurfacing. /// /// Contracts use Dune's trace-indexed `starknet.calls` (the Calls-tab -/// authority); accounts fall back to the pf event window (`FillGap`), whose +/// authority); without Dune (or for accounts) we fall back to the pf event +/// window (`FillGap`) — unkeyed for contracts, keyed for accounts — whose /// `tx_rows` project to the same call rows. #[allow(clippy::too_many_arguments)] pub(super) async fn run_call_gap_fill( @@ -2470,6 +2503,12 @@ pub(super) async fn run_call_gap_fill( format!("callgap:{}", query_addr_prefix(&address)), "Calls gap fill".to_string(), ); + // Clears the gap row's in-flight flag on any exit path (see guard docs). + let _finish_guard = CallGapFillGuard { + action_tx: action_tx.clone(), + address, + lo_block: gap.lo_block, + }; let _ = action_tx.send(Action::LoadingStatus(format!( "Filling calls gap (blocks {}..{})...", gap.lo_block, gap.hi_block @@ -2515,8 +2554,17 @@ pub(super) async fn run_call_gap_fill( } } } else if let Some(pf_client) = pf { - // Account (or no Dune): scan the gap's event window and project the - // pf tx_rows to call rows. + // Account (or a contract with no Dune): scan the gap's event window + // and project the pf tx_rows to call rows. The filter kind must + // match the address kind — the keyed `transaction_executed` filter + // (`Account`) targets an address as a tx *sender* and returns the + // wrong activity set for a contract, so contracts use the unkeyed + // contract-event filter (`Contract`). + let kind = if is_contract { + EventQueryKind::Contract + } else { + EventQueryKind::Account + }; let Some(latest_block) = ds.latest_block_hint().await else { warn!(addr = %format!("{:#x}", address), "Calls gap fill: no chain head available"); let _ = action_tx.send(Action::LoadingStatus(String::new())); @@ -2524,7 +2572,7 @@ pub(super) async fn run_call_gap_fill( }; match crate::network::event_window::ensure_address_events_window( address, - EventQueryKind::Account, + kind, crate::network::event_window::EventWindowPolicy::FillGap { from_block: gap.lo_block, to_block: gap.hi_block, @@ -5511,9 +5559,10 @@ mod shared_pipeline_tests { let address = Felt::from_hex(HYBRID_TEST_ADDR).unwrap(); // Single pipeline call — this is the claim under test. - let page = fetch_address_activity(address, EventQueryKind::Account, 0, None, 100, &pf) - .await - .expect("fetch_address_activity"); + let page = + fetch_address_activity(address, EventQueryKind::Account, 0, None, None, 100, &pf) + .await + .expect("fetch_address_activity"); println!( "Pipeline page: {} events, {} tx_rows, next_token={:?}", diff --git a/src/network/event_window.rs b/src/network/event_window.rs index cbfec67..b44665c 100644 --- a/src/network/event_window.rs +++ b/src/network/event_window.rs @@ -221,15 +221,26 @@ pub(crate) async fn ensure_address_events_window( let page = match pf { Some(pf_client) => { - fetch_address_activity(address, kind, from_block, None, EVENT_PAGE_LIMIT, pf_client) - .await - .inspect_err(|e| { - warn!( - address = %format!("{:#x}", address), - error = %e, - "event_window: fetch_address_activity failed" - ); - })? + // Pass `to_block` so a bounded policy (FillGap) actually scans + // `[from, to]` instead of running to the chain head — otherwise a + // gap fill keeps returning the newest page and never converges. + fetch_address_activity( + address, + kind, + from_block, + to_block, + None, + EVENT_PAGE_LIMIT, + pf_client, + ) + .await + .inspect_err(|e| { + warn!( + address = %format!("{:#x}", address), + error = %e, + "event_window: fetch_address_activity failed" + ); + })? } None => { // RPC fallback: just events, no bulk tx_rows. Callers that need diff --git a/src/ui/views/address_info.rs b/src/ui/views/address_info.rs index 350301a..7dae544 100644 --- a/src/ui/views/address_info.rs +++ b/src/ui/views/address_info.rs @@ -809,7 +809,7 @@ fn draw_calls_tab(f: &mut Frame, app: &mut App, area: Rect) { { if let Some((hi, dispatched)) = gap_info_for_lo(lo) { let msg = if dispatched { - format!(" ── calls gap (blocks {lo}..{hi}) — loading / press r to retry ──") + format!(" ── calls gap (blocks {lo}..{hi}) — loading… ──") } else { format!(" ── calls gap (blocks {lo}..{hi}) — press Enter to load ──") }; @@ -902,7 +902,7 @@ fn draw_calls_tab(f: &mut Frame, app: &mut App, area: Rect) { if any_pending { format!(" — {n} gap{plural} (Enter on a gap row to load) ") } else { - format!(" — {n} gap{plural} (loading / press r to retry) ") + format!(" — {n} gap{plural} (loading…) ") } }; let count = call_count_fragment(app);