Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/app/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,13 @@ pub enum Action {
address: Felt,
balances: Vec<TokenBalance>,
},
/// 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,
Expand Down
3 changes: 3 additions & 0 deletions src/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,9 @@ fn maybe_dispatch_meta_txs_on_entry(app: &mut App) -> Option<Action> {
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;
}
Expand Down
29 changes: 29 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
}
Expand Down
7 changes: 7 additions & 0 deletions src/app/views/address_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ pub struct AddressInfoState {
pub calls: StatefulList<ContractCallSummary>,
/// 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).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
21 changes: 21 additions & 0 deletions src/network/address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions src/network/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
));
Expand Down
23 changes: 22 additions & 1 deletion src/ui/views/address_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading