Skip to content

Commit 5687bee

Browse files
amanuskclaude
andauthored
Resolve ABIs at the tx's block for tx detail decoding (#24) (#25)
* increase endpoint field * Resolve ABIs at the tx's block for tx detail decoding (#24) Tx decoding was always picking up the *latest* ABI of each address it touched, so transactions executed before a contract upgrade rendered with the wrong event/call shape (e.g. STRK Transfer keys/data layout changed in the v0.14.2 upgrade). Resolve each address's class hash as of the tx's block, prefer the existing pf-query class_history cache, and fall back to RPC `getClassHashAt` when pf is unavailable. Pending txs and other code paths (block list, address view) keep using the latest ABI as before. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Gate cached class-history on max-block watermark; cargo fmt resolve_class_hash_at treated a cache as covering the target block whenever the oldest entry was old enough, but a stale cache could still be missing newer replace_class updates and return the wrong class. Require load_class_history_max_block(addr) >= block as well, so a stale cache forces a pf-query refetch (or RPC fallback). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2c2314f commit 5687bee

9 files changed

Lines changed: 265 additions & 33 deletions

File tree

src/data/cache.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,16 @@ impl DataSource for CachingDataSource {
11021102
Ok(class_hash)
11031103
}
11041104

1105+
async fn get_class_hash_at(&self, address: Felt, block: u64) -> Result<Felt> {
1106+
// The (address, block) → class_hash mapping is immutable, but the
1107+
// class_history table already caches it at coarser granularity for
1108+
// the addresses tx decoding cares about. Pass through here; the
1109+
// helper that wraps this (`resolve_class_hash_at`) consults
1110+
// class_history first and only hits this fallback when the cached
1111+
// history is incomplete.
1112+
self.upstream.get_class_hash_at(address, block).await
1113+
}
1114+
11051115
async fn get_class(&self, class_hash: Felt) -> Result<ContractClass> {
11061116
// Classes are large — pass through to upstream.
11071117
// Parsed ABIs are cached separately via the decode layer's class_cache.

src/data/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ pub trait DataSource: Send + Sync {
5757
async fn get_receipt(&self, hash: Felt) -> Result<SnReceipt>;
5858
async fn get_nonce(&self, address: Felt) -> Result<Felt>;
5959
async fn get_class_hash(&self, address: Felt) -> Result<Felt>;
60+
/// Class hash that was active for `address` at `block`. Used by tx
61+
/// decoding so old transactions render with the ABI that was deployed
62+
/// when they ran, not the current one. Default implementation falls
63+
/// back to `get_class_hash` (latest) — sources backed by RPC override
64+
/// to use `BlockId::Number(block)`.
65+
async fn get_class_hash_at(&self, address: Felt, _block: u64) -> Result<Felt> {
66+
self.get_class_hash(address).await
67+
}
6068
async fn get_class(&self, class_hash: Felt) -> Result<ContractClass>;
6169
async fn get_recent_blocks(&self, count: usize) -> Result<Vec<SnBlock>>;
6270
/// Fetch recent events emitted by or targeting an address.

src/data/rpc.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,13 @@ impl DataSource for RpcDataSource {
276276
.map_err(|e| SnbeatError::Provider(e.to_string()))
277277
}
278278

279+
async fn get_class_hash_at(&self, address: Felt, block: u64) -> Result<Felt> {
280+
self.provider
281+
.get_class_hash_at(BlockId::Number(block), address)
282+
.await
283+
.map_err(|e| SnbeatError::Provider(e.to_string()))
284+
}
285+
279286
async fn get_class(&self, class_hash: Felt) -> Result<ContractClass> {
280287
self.provider
281288
.get_class(BlockId::Tag(BlockTag::Latest), class_hash)

src/network/helpers.rs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,133 @@ pub async fn prewarm_abis(addresses: impl IntoIterator<Item = Felt>, abi_reg: &A
8080
futures::future::join_all(futs).await;
8181
}
8282

83+
/// Resolve the class hash that was active for `address` at `block`.
84+
///
85+
/// Tries in order:
86+
/// 1. Cached class_history (populated by address-view visits or earlier
87+
/// tx-decode calls). Pick the latest entry with `block_number <= block`.
88+
/// 2. pf-query `/class-history/{address}`, then save full history to cache.
89+
/// 3. RPC `starknet_getClassHashAt(BlockId::Number(block), address)`.
90+
/// Result is intentionally NOT written into class_history — that table
91+
/// is a list of class-hash *changes*, and a single point lookup would
92+
/// falsely indicate "this is the only class this address ever had".
93+
///
94+
/// Returns `None` if all three fail; callers should fall back to the
95+
/// latest-ABI path.
96+
pub async fn resolve_class_hash_at(
97+
address: Felt,
98+
block: u64,
99+
ds: &Arc<dyn DataSource>,
100+
pf: Option<&Arc<PathfinderClient>>,
101+
) -> Option<Felt> {
102+
// 1. cached class_history (desc-ordered).
103+
let mut history = ds.load_cached_class_history(&address);
104+
105+
// The cache "covers" the target block only if BOTH:
106+
// * the oldest cached entry is at/before `block` (cache reaches back
107+
// far enough to find the right class), AND
108+
// * pf-query has validated the cache forward through at least `block`
109+
// (no unobserved `replace_class` could sit between the newest cached
110+
// entry and `block`).
111+
// Skipping the forward check would let a stale cache satisfy a newer
112+
// target block and return the wrong class hash.
113+
let reaches_back = history
114+
.last()
115+
.map(|e| e.block_number <= block)
116+
.unwrap_or(false);
117+
let validated_through_target = ds
118+
.load_class_history_max_block(&address)
119+
.map(|max_block| max_block >= block)
120+
.unwrap_or(false);
121+
let cache_covers = reaches_back && validated_through_target;
122+
if !cache_covers && let Some(pf) = pf {
123+
match pf.get_class_history(address).await {
124+
Ok(entries) => {
125+
if !entries.is_empty() {
126+
let latest = ds.get_latest_block_number().await.unwrap_or(0);
127+
ds.save_class_history(&address, &entries);
128+
if latest > 0 {
129+
ds.save_class_history_max_block(&address, latest);
130+
}
131+
history = entries;
132+
}
133+
}
134+
Err(e) => {
135+
debug!(address = %format!("{:#x}", address), error = %e, "pf-query class-history fetch failed");
136+
}
137+
}
138+
}
139+
140+
// 2. scan desc-ordered history for first entry with block_number <= target
141+
for entry in &history {
142+
if entry.block_number <= block {
143+
if let Ok(felt) = Felt::from_hex(&entry.class_hash) {
144+
return Some(felt);
145+
}
146+
warn!(class_hash = %entry.class_hash, "Bad class_hash hex in class_history");
147+
break;
148+
}
149+
}
150+
151+
// 3. RPC fallback (don't cache — see doc comment).
152+
match ds.get_class_hash_at(address, block).await {
153+
Ok(ch) => Some(ch),
154+
Err(e) => {
155+
debug!(address = %format!("{:#x}", address), block, error = %e, "RPC get_class_hash_at failed");
156+
None
157+
}
158+
}
159+
}
160+
161+
/// Pre-warm ABIs for every address used in a tx, resolving each one's
162+
/// class hash *as of `block`* (not latest). Returns the address → class_hash
163+
/// map so callers can decode events / calls against the correct ABI by
164+
/// passing the resolved class to `AbiRegistry::get_abi_for_class` (which
165+
/// will be a cache hit after this prewarm).
166+
///
167+
/// Addresses that fail to resolve are simply omitted from the returned
168+
/// map; callers should fall back to `AbiRegistry::get_abi_for_address`
169+
/// (latest) for those.
170+
pub async fn prewarm_abis_at(
171+
addresses: impl IntoIterator<Item = Felt>,
172+
block: u64,
173+
ds: &Arc<dyn DataSource>,
174+
pf: Option<&Arc<PathfinderClient>>,
175+
abi_reg: &AbiRegistry,
176+
) -> HashMap<Felt, Felt> {
177+
let addrs: Vec<Felt> = addresses.into_iter().collect();
178+
179+
// Resolve all (address → class_hash @ block) in parallel.
180+
let resolutions = futures::future::join_all(
181+
addrs
182+
.iter()
183+
.copied()
184+
.map(|a| async move { (a, resolve_class_hash_at(a, block, ds, pf).await) }),
185+
)
186+
.await;
187+
188+
let mut addr_to_class: HashMap<Felt, Felt> = HashMap::new();
189+
let mut unique_classes: HashSet<Felt> = HashSet::new();
190+
for (addr, class_opt) in resolutions {
191+
if let Some(ch) = class_opt {
192+
addr_to_class.insert(addr, ch);
193+
unique_classes.insert(ch);
194+
}
195+
}
196+
197+
// Prewarm ABIs for each unique class_hash in parallel.
198+
let class_futs: Vec<_> = unique_classes
199+
.iter()
200+
.copied()
201+
.map(|ch| async move {
202+
let _ = abi_reg.get_abi_for_class(&ch).await;
203+
})
204+
.collect();
205+
futures::future::join_all(class_futs).await;
206+
207+
addr_to_class
208+
}
209+
83210
/// Extract execution status string from a receipt.
84211
pub fn receipt_status(receipt: Option<&SnReceipt>) -> String {
85212
receipt

src/network/mod.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,14 @@ pub async fn run_network_task(
130130
.await;
131131
}
132132
Action::FetchTransaction { hash } => {
133-
transaction::fetch_and_send_transaction(hash, &ds, &abi_reg, &tx).await;
133+
transaction::fetch_and_send_transaction(
134+
hash,
135+
&ds,
136+
pf.as_ref(),
137+
&abi_reg,
138+
&tx,
139+
)
140+
.await;
134141
}
135142
Action::FetchAddressInfo { address } => {
136143
let _ = tx.send(Action::NavigateToAddress { address });
@@ -184,7 +191,12 @@ pub async fn run_network_task(
184191
match ds.get_receipt(*hash).await {
185192
Ok(receipt) => {
186193
transaction::decode_and_send_transaction(
187-
fetched_tx, receipt, &ds, &abi_reg, &tx,
194+
fetched_tx,
195+
receipt,
196+
&ds,
197+
pf.as_ref(),
198+
&abi_reg,
199+
&tx,
188200
)
189201
.await;
190202
}

src/network/search.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ pub(super) async fn resolve_search(
6363
transaction,
6464
receipt,
6565
ds,
66+
pf.as_ref(),
6667
abi_reg,
6768
tx,
6869
)

src/network/transaction.rs

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,73 @@
11
//! Transaction-related network functions: fetching, decoding, and resolving call ABIs.
22
3+
use std::collections::HashMap;
34
use std::sync::Arc;
45

6+
use starknet::core::types::Felt;
57
use tokio::sync::mpsc;
68
use tracing::{debug, error, info};
79

810
use crate::app::actions::Action;
911
use crate::data::DataSource;
12+
use crate::data::pathfinder::PathfinderClient;
1013
use crate::data::types::SnTransaction;
1114
use crate::decode::AbiRegistry;
15+
use crate::decode::abi::ParsedAbi;
1216
use crate::decode::events::decode_event;
1317
use crate::decode::functions::parse_multicall;
1418
use crate::decode::outside_execution::{
1519
OutsideExecutionInfo, OutsideExecutionVersion, is_avnu_forwarder, is_outside_execution,
1620
looks_like_outside_execution, parse_forwarder_call, parse_outside_execution,
1721
};
1822

23+
/// Resolve the ABI for `addr` as of `block`. Prefers the prewarmed
24+
/// `addr_to_class` map; on miss, falls back to a synchronous resolve via
25+
/// `class_history` (cached or fetched). If that also fails, falls through
26+
/// to the latest-ABI path (same behaviour as pre-issue-#24 code) so any
27+
/// degradation is graceful.
28+
async fn abi_at_block(
29+
addr: &Felt,
30+
block: u64,
31+
addr_to_class: &HashMap<Felt, Felt>,
32+
ds: &Arc<dyn DataSource>,
33+
pf: Option<&Arc<PathfinderClient>>,
34+
abi_reg: &AbiRegistry,
35+
) -> Option<Arc<ParsedAbi>> {
36+
if let Some(class_hash) = addr_to_class.get(addr) {
37+
return abi_reg.get_abi_for_class(class_hash).await;
38+
}
39+
if block > 0
40+
&& let Some(class_hash) = super::helpers::resolve_class_hash_at(*addr, block, ds, pf).await
41+
{
42+
return abi_reg.get_abi_for_class(&class_hash).await;
43+
}
44+
abi_reg.get_abi_for_address(addr).await
45+
}
46+
1947
/// Resolve selector names, function definitions, and contract ABIs for a list of calls.
2048
/// Shared by all code paths that produce a `TransactionLoaded` action.
2149
pub(super) async fn resolve_call_abis(
2250
calls: &mut [crate::decode::functions::RawCall],
51+
block: u64,
52+
addr_to_class: &HashMap<Felt, Felt>,
53+
ds: &Arc<dyn DataSource>,
54+
pf: Option<&Arc<PathfinderClient>>,
2355
abi_reg: &Arc<AbiRegistry>,
2456
) {
2557
for call in calls.iter_mut() {
2658
if let Some(name) = abi_reg.get_selector_name(&call.selector) {
2759
call.function_name = Some(name);
2860
}
29-
if let Some(abi) = abi_reg.get_abi_for_address(&call.contract_address).await {
61+
if let Some(abi) = abi_at_block(
62+
&call.contract_address,
63+
block,
64+
addr_to_class,
65+
ds,
66+
pf,
67+
abi_reg,
68+
)
69+
.await
70+
{
3071
if let Some(func) = abi.get_function(&call.selector) {
3172
if call.function_name.is_none() {
3273
call.function_name = Some(func.name.clone());
@@ -43,42 +84,57 @@ pub(super) async fn decode_and_send_transaction(
4384
transaction: SnTransaction,
4485
receipt: crate::data::types::SnReceipt,
4586
ds: &Arc<dyn DataSource>,
87+
pf: Option<&Arc<PathfinderClient>>,
4688
abi_reg: &Arc<AbiRegistry>,
4789
action_tx: &mpsc::UnboundedSender<Action>,
4890
) {
91+
let block = receipt.block_number;
92+
4993
// Parse multicall up front so we can prewarm the ABI cache for every
5094
// unique address referenced by this tx (event sources + call targets)
51-
// in a single parallel round-trip. Every subsequent per-event /
52-
// per-call `get_abi_for_address` call then hits warm cache.
95+
// in a single parallel round-trip — resolving each address's class
96+
// hash *as of `block`* so post-upgrade contracts decode pre-upgrade
97+
// events correctly (issue #24). Subsequent per-event / per-call ABI
98+
// lookups consult the resolved (address → class_hash) map first.
5399
let mut decoded_calls = match &transaction {
54100
SnTransaction::Invoke(invoke) => parse_multicall(&invoke.calldata),
55101
_ => Vec::new(),
56102
};
57-
let mut prewarm_targets: std::collections::HashSet<starknet::core::types::Felt> =
103+
let mut prewarm_targets: std::collections::HashSet<Felt> =
58104
std::collections::HashSet::with_capacity(receipt.events.len() + decoded_calls.len());
59105
for event in &receipt.events {
60106
prewarm_targets.insert(event.from_address);
61107
}
62108
for call in &decoded_calls {
63109
prewarm_targets.insert(call.contract_address);
64110
}
65-
super::helpers::prewarm_abis(prewarm_targets, abi_reg).await;
111+
let addr_to_class = if block > 0 {
112+
super::helpers::prewarm_abis_at(prewarm_targets, block, ds, pf, abi_reg).await
113+
} else {
114+
// Pending tx (no block yet). Use latest-ABI prewarm.
115+
super::helpers::prewarm_abis(prewarm_targets, abi_reg).await;
116+
HashMap::new()
117+
};
66118

67119
let mut decoded_events = Vec::with_capacity(receipt.events.len());
68120
for event in &receipt.events {
69-
let abi = abi_reg.get_abi_for_address(&event.from_address).await;
121+
let abi = abi_at_block(&event.from_address, block, &addr_to_class, ds, pf, abi_reg).await;
70122
decoded_events.push(decode_event(event, abi.as_deref()));
71123
}
72-
resolve_call_abis(&mut decoded_calls, abi_reg).await;
73-
let outside_executions = detect_and_resolve_outside_executions(&decoded_calls, abi_reg).await;
124+
resolve_call_abis(&mut decoded_calls, block, &addr_to_class, ds, pf, abi_reg).await;
125+
let outside_executions = detect_and_resolve_outside_executions(
126+
&decoded_calls,
127+
block,
128+
&addr_to_class,
129+
ds,
130+
pf,
131+
abi_reg,
132+
)
133+
.await;
74134

75135
// Fetch block timestamp (used for age display and price lookups on tracked tokens).
76136
// Block fetches are cached, so repeat calls for the same block are cheap.
77-
let block_timestamp = ds
78-
.get_block(receipt.block_number)
79-
.await
80-
.ok()
81-
.map(|b| b.timestamp);
137+
let block_timestamp = ds.get_block(block).await.ok().map(|b| b.timestamp);
82138

83139
let _ = action_tx.send(Action::TransactionLoaded {
84140
transaction,
@@ -98,6 +154,10 @@ pub(super) async fn decode_and_send_transaction(
98154
/// 3. By known forwarder address (AVNU paymaster wraps outside execution in execute/execute_sponsored)
99155
async fn detect_and_resolve_outside_executions(
100156
calls: &[crate::decode::functions::RawCall],
157+
block: u64,
158+
addr_to_class: &HashMap<Felt, Felt>,
159+
ds: &Arc<dyn DataSource>,
160+
pf: Option<&Arc<PathfinderClient>>,
101161
abi_reg: &Arc<AbiRegistry>,
102162
) -> Vec<(usize, OutsideExecutionInfo)> {
103163
let mut results = Vec::new();
@@ -120,7 +180,7 @@ async fn detect_and_resolve_outside_executions(
120180
}
121181

122182
if let Some(mut oe) = oe {
123-
resolve_call_abis(&mut oe.inner_calls, abi_reg).await;
183+
resolve_call_abis(&mut oe.inner_calls, block, addr_to_class, ds, pf, abi_reg).await;
124184
results.push((i, oe));
125185
}
126186
}
@@ -132,6 +192,7 @@ async fn detect_and_resolve_outside_executions(
132192
pub(super) async fn fetch_and_send_transaction(
133193
hash: starknet::core::types::Felt,
134194
ds: &Arc<dyn DataSource>,
195+
pf: Option<&Arc<PathfinderClient>>,
135196
abi_reg: &Arc<AbiRegistry>,
136197
tx: &mpsc::UnboundedSender<Action>,
137198
) {
@@ -145,7 +206,7 @@ pub(super) async fn fetch_and_send_transaction(
145206
match (tx_result, receipt_result) {
146207
(Ok(transaction), Ok(receipt)) => {
147208
info!(tx_hash = %hash_short, elapsed_ms = start.elapsed().as_millis(), "Transaction fetched, decoding");
148-
decode_and_send_transaction(transaction, receipt, ds, abi_reg, tx).await;
209+
decode_and_send_transaction(transaction, receipt, ds, pf, abi_reg, tx).await;
149210
}
150211
(Err(e), _) | (_, Err(e)) => {
151212
error!(tx_hash = %hash_short, elapsed_ms = start.elapsed().as_millis(), error = %e, "Failed to fetch transaction");

0 commit comments

Comments
 (0)