From 5327c7d7c763c59b0a8d6704d571541f0619186b Mon Sep 17 00:00:00 2001 From: amanusk Date: Thu, 2 Jul 2026 15:08:28 +0300 Subject: [PATCH] feat(address): surface undeployed-but-funded addresses instead of "not found" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An address can hold token balances before it's ever deployed (funds sent to a counterfactual / not-yet-deployed account). Previously a search for such an address fell through every probe — no class, tx, block, or declared class — and reported "Not found as address, transaction, block hash, or class hash", even though it exists on-chain as a token-transfer recipient. Add a final check to the search resolver: if the hex resolves to nothing else but holds a non-zero balance in one of our known tokens, open the address view with a red "⚠ This contract is not deployed" note in the header (no class line, no deployed-at) and the balances populated. The note is set only once the live class fetch confirms absence (via a new AddressNotDeployed signal), so it never flashes during a normal address load, and it's gated on non-zero balances so a transient RPC error (which also nulls balances) can't trigger a false positive. Skip all enrichment for these addresses — an undeployed address has no txs, calls, events, or class history, so the full pipeline would only burn RPC/Dune/pf round trips for guaranteed-empty results: - the search path emits balances + the marker directly (single balanceOf batch, no pipeline, no redundant second balance fetch) - fetch_and_send_address_info returns right after the nonce/class join when the class is absent, covering the refresh / direct-nav paths - the MetaTxs tab no longer fires its pf-query bloom scan on entry Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/actions.rs | 7 +++++++ src/app/input.rs | 3 +++ src/app/mod.rs | 29 +++++++++++++++++++++++++++++ src/app/views/address_info.rs | 7 +++++++ src/network/address.rs | 21 +++++++++++++++++++++ src/network/search.rs | 25 +++++++++++++++++++++++++ src/ui/views/address_info.rs | 23 ++++++++++++++++++++++- 7 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/app/actions.rs b/src/app/actions.rs index 2b9f426..af7cbc0 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -406,6 +406,13 @@ pub enum Action { address: Felt, balances: Vec, }, + /// The address has no class at the latest block (`get_class_hash` + /// errored) — it exists on-chain only as a token-transfer recipient. + /// The header shows a red "not deployed" note (gated on non-zero + /// balances) instead of a class/deploy line. + AddressNotDeployed { + address: Felt, + }, /// Voyager label loaded for an address. VoyagerLabelLoaded { address: Felt, diff --git a/src/app/input.rs b/src/app/input.rs index c6e0337..d477069 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -540,6 +540,9 @@ fn maybe_dispatch_meta_txs_on_entry(app: &mut App) -> Option { if app.address.tab != crate::app::AddressTab::MetaTxs || app.address.meta_txs_dispatched || app.address.fetching_meta_txs + // Undeployed addresses have no meta-txs to scan for — skip the + // pf-query bloom walk entirely (it can only return empty). + || app.address.not_deployed { return None; } diff --git a/src/app/mod.rs b/src/app/mod.rs index 4005273..6afebb2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1351,6 +1351,12 @@ impl App { info.nonce == starknet::core::types::Felt::ZERO && info.class_hash.is_some(); self.address.is_contract = is_contract; + // A real class hash proves the address is deployed — clear any + // "not deployed" note a prior (class-less) emit may have set. + if info.class_hash.is_some() { + self.address.not_deployed = false; + } + // Preserve any balances that may have already arrived — the // balance task runs in parallel with the nonce/class_hash // fetch and can land first, and every callsite constructs @@ -2199,6 +2205,29 @@ impl App { self.dispatch_balance_price_fetch(); } } + Action::AddressNotDeployed { address } => { + if self.address.context == Some(address) { + self.address.not_deployed = true; + // Seed a minimal info so the header renders even if the + // balance task hasn't landed yet; `AddressBalancesLoaded` + // fills token_balances in-place when it arrives. + if self.address.info.is_none() { + self.address.info = Some(crate::data::types::SnAddressInfo { + address, + nonce: starknet::core::types::Felt::ZERO, + class_hash: None, + recent_events: Vec::new(), + token_balances: Vec::new(), + }); + } + // Loading is complete — an undeployed address has nothing + // left to fetch. Clear the spinner and any pending source + // so the view doesn't show a perpetual loader. + self.address.sources_pending.clear(); + self.is_loading = false; + self.loading_detail = None; + } + } Action::PricesUpdated => { // Cache reads happen on next draw; no app state to update. } diff --git a/src/app/views/address_info.rs b/src/app/views/address_info.rs index 57b7136..4185786 100644 --- a/src/app/views/address_info.rs +++ b/src/app/views/address_info.rs @@ -135,6 +135,11 @@ pub struct AddressInfoState { pub calls: StatefulList, /// Whether this address is a contract (nonce == 0) vs an account. pub is_contract: bool, + /// The address has no class at the latest block (`get_class_hash` + /// errored) — not deployed, exists only as a token-transfer recipient. + /// Set once the live class fetch confirms absence; the header renders a + /// red "not deployed" note (gated on non-zero balances). + pub not_deployed: bool, /// Whether header is in visual (item-selection) mode. pub visual_mode: bool, /// Navigable items in the address header (class hash, deploy tx, deploy block, deployer). @@ -278,6 +283,7 @@ impl Default for AddressInfoState { deployment: None, calls: StatefulList::new(), is_contract: false, + not_deployed: false, visual_mode: false, nav_items: Vec::new(), nav_cursor: 0, @@ -334,6 +340,7 @@ impl AddressInfoState { self.txs = StatefulList::new(); self.deployment = None; self.calls = StatefulList::new(); + self.not_deployed = false; self.fetching_more_txs = false; self.oldest_event_block = None; self.sources_pending.clear(); diff --git a/src/network/address.rs b/src/network/address.rs index f03888d..17881a1 100644 --- a/src/network/address.rs +++ b/src/network/address.rs @@ -985,6 +985,27 @@ pub(super) async fn fetch_and_send_address_info( // Detect contract type: nonce == 0 + has class_hash → contract, not account. let is_contract = nonce == starknet::core::types::Felt::ZERO && class_hash.is_some(); + // No class at the latest block means the address isn't deployed — it + // exists on-chain only as a token-transfer recipient (funds sent to a + // counterfactual / not-yet-deployed account). Signal it so the header + // can show a red "not deployed" note. The UI gates the note on non-zero + // balances, so a transient RPC error here (which also nulls balances) + // can't produce a false positive. + // + // Stop here: an undeployed address has no txs, calls, events, or class + // history, so the entire downstream enrichment (Dune activity probe, + // pf-query class history, event-window scans, tx/call fetches) would + // only burn round trips for guaranteed-empty results. Balances were + // already fetched above (the spawn near the top of this fn) and are all + // that matters until the address is deployed. This early return also + // covers the refresh / direct-navigation paths; the search path never + // reaches here (it emits the not-deployed marker itself, see + // `resolve_search`). + if class_hash.is_none() { + let _ = tx.send(Action::AddressNotDeployed { address }); + return; + } + // Nonce delta against cache. The downstream pipeline uses this to // narrow the missing-tx scan; gap fill runs after this point. let nonce_delta = if let Some((prev_nonce, _prev_block)) = &cached_nonce_info { diff --git a/src/network/search.rs b/src/network/search.rs index 5f70454..d852b57 100644 --- a/src/network/search.rs +++ b/src/network/search.rs @@ -109,6 +109,31 @@ pub(super) async fn resolve_search( return; } + // Last check: an address can hold token balances before it's ever + // deployed (funds sent to a counterfactual / not-yet-deployed + // account). None of the probes above match — there's no class, tx, + // block, or declared class — but the address still exists on-chain + // as a token-transfer recipient. Surface it as an address view (the + // header renders a red "not deployed" note) instead of "not found". + // + // Emit the balances + not-deployed marker directly rather than + // running `fetch_and_send_address_info`: an undeployed address has + // no txs, calls, events, or class history, so the full pipeline + // would only burn RPC/Dune/pf round trips for guaranteed-empty + // results. We already know the class is absent (the `get_class_hash` + // probe above failed), and balances are the only thing that matters + // until the address is deployed. + let balances = address::fetch_token_balances(felt, ds).await; + if !balances.is_empty() { + let _ = tx.send(Action::NavigateToAddress { address: felt }); + let _ = tx.send(Action::AddressBalancesLoaded { + address: felt, + balances, + }); + let _ = tx.send(Action::AddressNotDeployed { address: felt }); + return; + } + let _ = tx.send(Action::Error( "Not found as address, transaction, block hash, or class hash".to_string(), )); diff --git a/src/ui/views/address_info.rs b/src/ui/views/address_info.rs index 7dae544..33bdd6b 100644 --- a/src/ui/views/address_info.rs +++ b/src/ui/views/address_info.rs @@ -190,8 +190,18 @@ pub fn draw(f: &mut Frame, app: &mut App) { .is_some_and(|s| { app.address.info.as_ref().is_none_or(|i| s != i.address) && s != Felt::ZERO }); + // The red "not deployed" note (matches the gate in `draw_header`) adds + // one line — reserve room for it so it isn't clipped. + let has_not_deployed_note = app.address.not_deployed + && app + .address + .info + .as_ref() + .is_some_and(|i| i.class_hash.is_none() && !i.token_balances.is_empty()); // 2 borders + 2 base lines + 1 per deployment line (tx hash, deployer) - let header_height = 4 + u16::from(has_deploy) + u16::from(has_deployer); + // + 1 for the not-deployed note when shown. + let header_height = + 4 + u16::from(has_deploy) + u16::from(has_deployer) + u16::from(has_not_deployed_note); let chunks = Layout::vertical([ Constraint::Length(1), // search bar Constraint::Length(header_height), // header @@ -392,6 +402,17 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { } } + // Not-deployed note: the address holds token balances but has no class + // at the latest block (funds sent to a counterfactual / not-yet-deployed + // account). Gated on non-zero balances so a transient RPC error can't + // trigger a false positive. + if app.address.not_deployed && info.class_hash.is_none() && !info.token_balances.is_empty() { + lines.push(Line::from(Span::styled( + " ⚠ This contract is not deployed", + theme::STATUS_ERROR.add_modifier(Modifier::BOLD), + ))); + } + let widget = Paragraph::new(lines).block( Block::default() .borders(Borders::ALL)