Skip to content

Commit 69a4036

Browse files
amanuskclaude
andauthored
Scope cold-cache calls Dune query, surface UDC v2 deploys (#26)
* Address view: scope cold-cache calls Dune query and surface UDC v2 deploys Three related fixes for visiting a recently deployed contract for the first time: - The cold-cache Dune contract-calls path used a `block_date >= '2024-01-01'` floor, so a partition scan over 16 months ran even when the contract had been deployed yesterday. We now resolve a deploy block via cache → pf class-history → Voyager, look up its timestamp through `ds.get_block` (cache → RPC), and pass `min_block_date = deploy_date - 1 day` to the windowed query. Verified against Dune: identical rows at ~18x lower cost. - `find_deploy_tx` was only invoked from the pf re-fetch branch, so when another flow (tx-detail decode, class-info view) had already warmed `class_history` but not `deploy_info`, the deploy summary never appeared. Trigger the lookup whenever class history is cached but deploy info is missing; suppress the duplicate launch from the pf branch. - Register UDC v2 (0x02ceed65...) so deployments through it carry a label. The `ContractDeployed` selector is unchanged, and our scanners already match by event key rather than `from_address`, so v2 flows through the same UDC path without further changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Voyager: wait for in-flight fetch instead of returning empty The previous in-flight dedup short-circuited concurrent callers to an empty `VoyagerLabelInfo`. That was fine for the original fire-and-forget UI label use case, but broke a downstream consumer: when the cold-cache contract-calls path tried to read `deploy_block` from voyager while the orchestration's voyager fetch was already mid-flight, it got `None` and fell back to the unscoped Dune query — exactly the slow path the dedup was supposed to help. Replace the `HashSet<Felt>` slot tracker with a per-address `tokio::sync::Mutex` map. The first caller acquires the lock and performs the fetch; concurrent callers wait, then read the cached result. Backoff and concurrency-cap behaviour are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Extract calls Dune query plan into a pure helper, add unit tests The cold-cache calls path picks between three Dune query variants (top-delta, deploy-scoped windowed, unwindowed fallback) based on a chain of cache + pf + Voyager lookups. Regressions back to the legacy unwindowed `block_date >= '2024-01-01'` query are silent (just slow), so the choice is worth covering with tests. Pull the decision into `pick_calls_dune_query` — a sync function over plain inputs (newest cached call's block+timestamp, deploy floor, deploy timestamp). `fetch_address_contract_calls` does the IO to populate those inputs and dispatches the resulting `CallsDuneQuery` to the matching Dune method. No behaviour change. Add six unit tests covering: warm cache (with and without a usable timestamp), warm cache ignoring a deploy floor when one is also resolved, cold cache + deploy_info, cold cache with no floor at all, and cold cache with a floor but no timestamp (must fall back to unwindowed since the windowed variant degrades to a full partition scan without a date hint). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5687bee commit 69a4036

3 files changed

Lines changed: 385 additions & 69 deletions

File tree

src/network/address.rs

Lines changed: 282 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -825,8 +825,25 @@ pub(super) async fn fetch_and_send_address_info(
825825
// immediately on hit, so no network work runs here.
826826
find_deploy_tx(address, cached_deploy_block, &ds_c, &tx_c).await;
827827
});
828+
} else if let Some(earliest) = cached_class_history.last() {
829+
// No deploy_info cached, but class history is — meaning a previous
830+
// flow (tx-detail decode, class info view) warmed class history but
831+
// never triggered the deploy-tx scan. Kick it off here using the
832+
// earliest cached entry as the deploy block. Without this, the pf
833+
// re-fetch branch below only fires the lookup when the cache is
834+
// *stale*, so a fresh-but-deploy-info-less cache would never show
835+
// the deployment tx.
836+
let deploy_block = earliest.block_number;
837+
let ds_c = Arc::clone(ds);
838+
let tx_c = tx.clone();
839+
spawn_cancellable(cancel.clone(), async move {
840+
find_deploy_tx(address, deploy_block, &ds_c, &tx_c).await;
841+
});
828842
}
829843
let had_cached_deploy = cached_deploy.is_some();
844+
// Whether the cached fast-path above already kicked off a find_deploy_tx
845+
// run. Used to suppress a duplicate launch from the pf re-fetch branch.
846+
let kicked_deploy_lookup = had_cached_deploy || !cached_class_history.is_empty();
830847

831848
// Decide whether the cached class-history is still authoritative. If the
832849
// live class_hash matches the latest cached entry, no replace_class can
@@ -868,9 +885,12 @@ pub(super) async fn fetch_and_send_address_info(
868885
ds_c.save_class_history(&addr, &entries);
869886
ds_c.save_class_history_max_block(&addr, latest_block);
870887
// Skip the deploy-tx lookup if the cached fast-path
871-
// already emitted the deploy summary above. Avoids a
872-
// duplicate AddressTxsStreamed for the same hash.
873-
if !had_cached_deploy && let Some(deploy_entry) = entries.last() {
888+
// (or the cached-class-history fallback) already
889+
// kicked one off. find_deploy_tx itself is cache-first,
890+
// but launching it twice causes duplicate
891+
// AddressTxsStreamed emits when both runs miss cache
892+
// and race the same block scan.
893+
if !kicked_deploy_lookup && let Some(deploy_entry) = entries.last() {
874894
let deploy_block = deploy_entry.block_number;
875895
let tx_c2 = tx_c.clone();
876896
let ds_c2 = Arc::clone(&ds_c);
@@ -1231,13 +1251,15 @@ pub(super) async fn fetch_and_send_address_info(
12311251
let abi_b = Arc::clone(abi_reg);
12321252
let dune_b = dune.as_ref().map(Arc::clone);
12331253
let pf_b = pf.as_ref().map(Arc::clone);
1254+
let voyager_b = voyager_c.as_ref().map(Arc::clone);
12341255
spawn_cancellable(cancel.clone(), async move {
12351256
let _ = tx_b.send(Action::LoadingStatus("Calls: fetching from Dune...".into()));
12361257
fetch_address_contract_calls(
12371258
address,
12381259
&ds_b,
12391260
dune_b.as_ref(),
12401261
pf_b.as_ref(),
1262+
voyager_b.as_ref(),
12411263
&abi_b,
12421264
&tx_b,
12431265
nonce,
@@ -3400,6 +3422,74 @@ pub(super) async fn fetch_address_meta_txs(
34003422
});
34013423
}
34023424

3425+
/// Which Dune query variant the calls fetch should issue.
3426+
///
3427+
/// Pulled out as a pure decision so the choice can be unit-tested without
3428+
/// mocking Dune/PF/Voyager — see `pick_calls_dune_query`.
3429+
#[derive(Debug, PartialEq, Eq)]
3430+
pub(super) enum CallsDuneQuery {
3431+
/// We have cached calls. Pull only blocks newer than the highest cached
3432+
/// row; reuse the cached row's date as a partition hint.
3433+
TopDelta {
3434+
from_block: u64,
3435+
min_date: Option<chrono::NaiveDate>,
3436+
},
3437+
/// Cold cache, but we resolved the deploy block AND its timestamp.
3438+
/// Scope the windowed query to `[deploy_block, ∞)` with the deploy
3439+
/// date (minus a 1-day cushion) as the partition hint.
3440+
DeployScoped {
3441+
from_block: u64,
3442+
min_date: chrono::NaiveDate,
3443+
},
3444+
/// Cold cache and either no deploy floor or no timestamp for it.
3445+
/// Falls back to the legacy unwindowed `block_date >= '2024-01-01'`
3446+
/// query — slow on dense contracts but always correct.
3447+
Unwindowed,
3448+
}
3449+
3450+
/// Choose the Dune query variant for the contract-calls fetch.
3451+
///
3452+
/// The two inputs that drive the choice:
3453+
/// * `newest_cached_pair` — `Some((block_number, timestamp))` if we have
3454+
/// prior cached calls. `timestamp == 0` means "block known but date
3455+
/// unknown" — we drop the partition hint in that case.
3456+
/// * `deploy_floor` + `deploy_floor_ts` — both `Some` to qualify for
3457+
/// `DeployScoped`. Either being `None` collapses to `Unwindowed`.
3458+
///
3459+
/// `min_date` for the windowed variants is `block_date - 1 day` to guard
3460+
/// against the pinning row sitting right before a UTC day boundary.
3461+
pub(super) fn pick_calls_dune_query(
3462+
newest_cached_pair: Option<(u64, u64)>,
3463+
deploy_floor: Option<u64>,
3464+
deploy_floor_ts: Option<u64>,
3465+
) -> CallsDuneQuery {
3466+
if let Some((block, ts)) = newest_cached_pair {
3467+
let min_date = (ts > 0)
3468+
.then(|| chrono::DateTime::from_timestamp(ts as i64, 0))
3469+
.flatten()
3470+
.map(|dt| dt.date_naive() - chrono::Duration::days(1));
3471+
return CallsDuneQuery::TopDelta {
3472+
from_block: block + 1,
3473+
min_date,
3474+
};
3475+
}
3476+
3477+
match (deploy_floor, deploy_floor_ts) {
3478+
(Some(from_block), Some(ts)) if ts > 0 => {
3479+
match chrono::DateTime::from_timestamp(ts as i64, 0)
3480+
.map(|dt| dt.date_naive() - chrono::Duration::days(1))
3481+
{
3482+
Some(min_date) => CallsDuneQuery::DeployScoped {
3483+
from_block,
3484+
min_date,
3485+
},
3486+
None => CallsDuneQuery::Unwindowed,
3487+
}
3488+
}
3489+
_ => CallsDuneQuery::Unwindowed,
3490+
}
3491+
}
3492+
34033493
/// Fetch calls-to-contract for `address` via Dune and emit the resulting
34043494
/// [`ContractCallSummary`](crate::data::types::ContractCallSummary) rows as
34053495
/// `AddressInfoLoaded { contract_calls, .. }`.
@@ -3418,6 +3508,7 @@ pub(super) async fn fetch_address_contract_calls(
34183508
ds: &Arc<dyn crate::data::DataSource>,
34193509
dune: Option<&Arc<dune::DuneClient>>,
34203510
pf: Option<&Arc<crate::data::pathfinder::PathfinderClient>>,
3511+
voyager_c: Option<&Arc<voyager::VoyagerClient>>,
34213512
abi_reg: &Arc<AbiRegistry>,
34223513
action_tx: &mpsc::UnboundedSender<Action>,
34233514
nonce: starknet::core::types::Felt,
@@ -3453,28 +3544,115 @@ pub(super) async fn fetch_address_contract_calls(
34533544
// dense contracts. A 1-day UTC cushion guards against the cached row
34543545
// sitting right before a day boundary.
34553546
let cached_calls = ds.load_cached_address_calls(&address);
3456-
let newest_cached = cached_calls
3547+
let newest_cached_pair = cached_calls
34573548
.iter()
34583549
.filter(|c| c.block_number > 0)
3459-
.max_by_key(|c| c.block_number);
3460-
3461-
let dune_calls_result = match newest_cached {
3462-
Some(c) => {
3463-
let min_date = (c.timestamp > 0)
3464-
.then(|| chrono::DateTime::from_timestamp(c.timestamp as i64, 0))
3465-
.flatten()
3466-
.map(|dt| dt.date_naive() - chrono::Duration::days(1));
3550+
.max_by_key(|c| c.block_number)
3551+
.map(|c| (c.block_number, c.timestamp));
3552+
3553+
// Cold-cache path: resolve a deploy-block floor before deciding the Dune
3554+
// query variant. Sources, in order: cached deploy_info → cached
3555+
// class_history → pf class-history (~500ms) → Voyager label (~600ms).
3556+
// Either pf or voyager unblocks the deploy-scoped query; the cold path
3557+
// works against RPC + Voyager alone when pf isn't wired up.
3558+
let mut deploy_floor: Option<u64> = None;
3559+
if newest_cached_pair.is_none() {
3560+
deploy_floor = ds
3561+
.load_cached_deploy_info(&address)
3562+
.map(|(_, block, _)| block)
3563+
.or_else(|| {
3564+
ds.load_cached_class_history(&address)
3565+
.iter()
3566+
.map(|e| e.block_number)
3567+
.min()
3568+
});
3569+
3570+
if deploy_floor.is_none()
3571+
&& let Some(pf_client) = pf
3572+
{
3573+
match pf_client.get_class_history(address).await {
3574+
Ok(entries) => {
3575+
if !entries.is_empty() {
3576+
ds.save_class_history(&address, &entries);
3577+
}
3578+
deploy_floor = entries.iter().map(|e| e.block_number).min();
3579+
}
3580+
Err(e) => {
3581+
debug!(
3582+
addr = %format!("{:#x}", address),
3583+
error = %e,
3584+
"Calls: pf class-history fetch failed; trying Voyager"
3585+
);
3586+
}
3587+
}
3588+
}
3589+
3590+
if deploy_floor.is_none()
3591+
&& let Some(vc) = voyager_c
3592+
{
3593+
match vc.get_label(address).await {
3594+
Ok(label) => deploy_floor = label.deploy_block,
3595+
Err(e) => {
3596+
debug!(
3597+
addr = %format!("{:#x}", address),
3598+
error = %e,
3599+
"Calls: Voyager label fetch failed"
3600+
);
3601+
}
3602+
}
3603+
}
3604+
}
3605+
3606+
// Block timestamp for the deploy floor. `ds.get_block` serves from cache
3607+
// or falls back to RPC, so this works without pf.
3608+
let deploy_floor_ts = match deploy_floor {
3609+
Some(b) => ds.get_block(b).await.ok().map(|blk| blk.timestamp),
3610+
None => None,
3611+
};
3612+
3613+
let plan = pick_calls_dune_query(newest_cached_pair, deploy_floor, deploy_floor_ts);
3614+
if let CallsDuneQuery::DeployScoped {
3615+
from_block,
3616+
min_date,
3617+
} = &plan
3618+
{
3619+
debug!(
3620+
addr = %format!("{:#x}", address),
3621+
from_block,
3622+
?min_date,
3623+
"Calls: cold-cache windowed Dune query (deploy-scoped)"
3624+
);
3625+
}
3626+
let dune_calls_result = match plan {
3627+
CallsDuneQuery::TopDelta {
3628+
from_block,
3629+
min_date,
3630+
} => {
34673631
dune_client
34683632
.query_contract_calls_windowed(
34693633
address,
3470-
c.block_number + 1,
3634+
from_block,
34713635
u64::MAX,
34723636
CONTRACT_CALL_LIMIT,
34733637
min_date,
34743638
)
34753639
.await
34763640
}
3477-
None => {
3641+
CallsDuneQuery::DeployScoped {
3642+
from_block,
3643+
min_date,
3644+
} => {
3645+
dune_client
3646+
.query_contract_calls_windowed(
3647+
address,
3648+
from_block,
3649+
u64::MAX,
3650+
CONTRACT_CALL_LIMIT,
3651+
Some(min_date),
3652+
)
3653+
.await
3654+
}
3655+
CallsDuneQuery::Unwindowed => {
34783656
dune_client
34793657
.query_contract_calls(address, CONTRACT_CALL_LIMIT)
34803658
.await
@@ -4298,6 +4476,96 @@ mod tests {
42984476
}
42994477
}
43004478

4479+
// === pick_calls_dune_query unit tests ===
4480+
//
4481+
// The cold-cache calls path resolves a deploy floor through up to four
4482+
// sources (deploy_info → class_history → pf → Voyager) before deciding
4483+
// which Dune query variant to issue. Regressions back to the legacy
4484+
// unwindowed `block_date >= '2024-01-01'` query are silent (just slow,
4485+
// not wrong), so we test the decision exhaustively.
4486+
4487+
/// 2025-04-21 00:00:00 UTC — a representative deploy timestamp used
4488+
/// across these tests.
4489+
const DEPLOY_TS: u64 = 1_745_193_600;
4490+
/// 2025-04-21 minus the 1-day cushion `pick_calls_dune_query` applies.
4491+
fn deploy_min_date() -> chrono::NaiveDate {
4492+
chrono::NaiveDate::from_ymd_opt(2025, 4, 20).unwrap()
4493+
}
4494+
4495+
#[test]
4496+
fn warm_cache_uses_top_delta_with_date_hint() {
4497+
let plan = pick_calls_dune_query(Some((9_148_000, DEPLOY_TS)), None, None);
4498+
assert_eq!(
4499+
plan,
4500+
CallsDuneQuery::TopDelta {
4501+
from_block: 9_148_001,
4502+
min_date: Some(deploy_min_date()),
4503+
}
4504+
);
4505+
}
4506+
4507+
#[test]
4508+
fn warm_cache_with_zero_timestamp_drops_date_hint() {
4509+
// A cached row with `timestamp == 0` (e.g. enrichment never landed)
4510+
// must not produce `min_date = 1969-12-31`.
4511+
let plan = pick_calls_dune_query(Some((9_148_000, 0)), Some(9_013_975), Some(DEPLOY_TS));
4512+
assert_eq!(
4513+
plan,
4514+
CallsDuneQuery::TopDelta {
4515+
from_block: 9_148_001,
4516+
min_date: None,
4517+
}
4518+
);
4519+
}
4520+
4521+
#[test]
4522+
fn warm_cache_ignores_deploy_floor() {
4523+
// TopDelta wins over DeployScoped: if we already have cached rows
4524+
// there's no need to scan all the way back to deploy.
4525+
let plan = pick_calls_dune_query(
4526+
Some((9_148_000, DEPLOY_TS)),
4527+
Some(9_013_975),
4528+
Some(DEPLOY_TS),
4529+
);
4530+
match plan {
4531+
CallsDuneQuery::TopDelta { from_block, .. } => assert_eq!(from_block, 9_148_001),
4532+
other => panic!("expected TopDelta, got {:?}", other),
4533+
}
4534+
}
4535+
4536+
#[test]
4537+
fn cold_cache_with_deploy_info_uses_deploy_scoped() {
4538+
let plan = pick_calls_dune_query(None, Some(9_013_975), Some(DEPLOY_TS));
4539+
assert_eq!(
4540+
plan,
4541+
CallsDuneQuery::DeployScoped {
4542+
from_block: 9_013_975,
4543+
min_date: deploy_min_date(),
4544+
}
4545+
);
4546+
}
4547+
4548+
#[test]
4549+
fn cold_cache_without_deploy_floor_falls_back_to_unwindowed() {
4550+
let plan = pick_calls_dune_query(None, None, None);
4551+
assert_eq!(plan, CallsDuneQuery::Unwindowed);
4552+
}
4553+
4554+
#[test]
4555+
fn cold_cache_with_floor_but_no_timestamp_falls_back() {
4556+
// Voyager gave us deploy_block but ds.get_block failed (or returned
4557+
// a sentinel `0` timestamp). Without a real date we can't prune
4558+
// partitions, so the windowed query is no better than unwindowed —
4559+
// emit the unwindowed query, which is what the comment on
4560+
// `query_contract_calls_windowed` says is required to avoid
4561+
// QUERY_STATE_FAILED on dense contracts.
4562+
let plan = pick_calls_dune_query(None, Some(9_013_975), None);
4563+
assert_eq!(plan, CallsDuneQuery::Unwindowed);
4564+
4565+
let plan_zero_ts = pick_calls_dune_query(None, Some(9_013_975), Some(0));
4566+
assert_eq!(plan_zero_ts, CallsDuneQuery::Unwindowed);
4567+
}
4568+
43014569
/// Smoke test that `batch_call_contracts` matches individual `call_contract`
43024570
/// results. Guards against regressions where the batched path silently
43034571
/// returns results in the wrong order.

0 commit comments

Comments
 (0)