diff --git a/.config/forest.dic b/.config/forest.dic index c9d66dcaef7..6874a21f991 100644 --- a/.config/forest.dic +++ b/.config/forest.dic @@ -1,4 +1,4 @@ -295 +296 ABI Algorand/M API's @@ -36,6 +36,7 @@ bootstrapper/S BuildKit butterflynet bytecode +cacheable Caddy calibnet calldata diff --git a/CHANGELOG.md b/CHANGELOG.md index 95e41ac6752..584acaa026e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ ### Changed +- [#7356](https://github.com/ChainSafe/forest/issues/7356): Ethereum transaction receipts and ID-to-address resolutions are now cached once the EC finality calculator considers them final, instead of waiting for the static 900-epoch chain finality. + - [#7535](https://github.com/ChainSafe/forest/pull/7535): `Filecoin.Version` now reports the API version of the endpoint being served (`1.5.0` over `/rpc/v0`, `2.3.0` over `/rpc/v1`), matching Lotus, and `Filecoin.SyncSubmitBlock` no longer requires the node to be in the `Synced` state and waits up to one block time for the submitted block to become the chain head. Together these let Forest act as the full node for an external block producer such as `lotus-miner` or `curio` (verified on a local devnet, calibnet/mainnet tests to come). ### Removed diff --git a/src/chain/store/chain_store.rs b/src/chain/store/chain_store.rs index b87a8a015ab..dc675090aed 100644 --- a/src/chain/store/chain_store.rs +++ b/src/chain/store/chain_store.rs @@ -51,7 +51,7 @@ pub type ChainEpochDelta = ChainEpoch; /// Outcome of [`ChainStore::resolve_to_deterministic_address_at_finality`]. pub enum AtFinalityResolution { - /// Resolved against a tipset at least `chain_finality` epochs behind the + /// Resolved against a tipset at least `ec_depth` epochs behind the /// requested one. The mapping is identical on every possible future /// chain, so it is safe to memoize by actor ID alone. ReorgStable(Address), @@ -374,12 +374,12 @@ impl ChainStore { &self.chain_config } - /// Resolves `addr` to its deterministic (public-key or delegated) form - /// using the state at `chain_finality` epochs behind `ts`. Falls back to - /// `ts` itself when the chain is younger than finality; the returned - /// [`AtFinalityResolution`] says which of the two happened. + /// Resolve `addr` to a public-key/delegated address. /// - /// Matches the logic at + /// Looks back `ec_depth` epochs from `ts`, where `ec_depth` is how far the + /// EC calculator has finalized behind head. Uses `ts` itself if it is not + /// deeper than `ec_depth`. [`AtFinalityResolution::ReorgStable`] (cacheable) + /// only when that lookback ran. pub fn resolve_to_deterministic_address_at_finality( &self, addr: &Address, @@ -389,23 +389,44 @@ impl ChainStore { match addr.protocol() { BLS | Secp256k1 | Delegated => Ok(AtFinalityResolution::ReorgStable(*addr)), ID => { - let finality_deep = ts.epoch() > self.chain_config().policy.chain_finality; - let lookback_ts = if finality_deep { - self.chain_index().load_required_tipset_by_height_blocking( - ts.epoch() - self.chain_config().policy.chain_finality, - ts.shallow_clone(), - ResolveNullTipset::TakeOlder, - )? + let ec_depth = + (self.heaviest_tipset().epoch() - self.ec_calculator_finalized_epoch()).max(0); + if ec_depth > 0 && ts.epoch() > ec_depth { + let lookback_ts = self + .chain_index() + .load_required_tipset_by_height_blocking( + ts.epoch() - ec_depth, + ts.shallow_clone(), + ResolveNullTipset::TakeOlder, + ) + .with_context(|| { + format!( + "failed to load EC-finality lookback at epoch {} from {}", + ts.epoch() - ec_depth, + ts.key() + ) + })?; + let state = StateTree::new_from_root(self.db(), lookback_ts.parent_state()) + .with_context(|| { + format!( + "failed to load state for EC-finality lookback {}", + lookback_ts.key() + ) + })?; + let resolved = state + .resolve_to_deterministic_address(self.db(), *addr) + .with_context(|| { + format!( + "failed to resolve ID address {addr} at {}", + lookback_ts.key() + ) + })?; + Ok(AtFinalityResolution::ReorgStable(resolved)) } else { - ts.shallow_clone() - }; - let state = StateTree::new_from_root(self.db(), lookback_ts.parent_state())?; - let resolved = state.resolve_to_deterministic_address(self.db(), *addr)?; - Ok(if finality_deep { - AtFinalityResolution::ReorgStable(resolved) - } else { - AtFinalityResolution::Unstable(resolved) - }) + let state = StateTree::new_from_root(self.db(), ts.parent_state())?; + let resolved = state.resolve_to_deterministic_address(self.db(), *addr)?; + Ok(AtFinalityResolution::Unstable(resolved)) + } } Actor => anyhow::bail!("Cannot resolve actor address to key address"), } @@ -875,6 +896,32 @@ mod tests { assert_eq!(cs.genesis_block_header(), &gen_block); } + #[test] + fn ec_finalized_epoch_falls_back_to_chain_finality_on_degraded_chain() { + use crate::chain::store::index::tests::{genesis_tipset, persist_tipset, tipset_child}; + + const CHAIN_FINALITY: ChainEpoch = 10; + + let db = Arc::new(crate::db::MemoryDB::default()); + let mut chain_config = ChainConfig::default(); + chain_config.policy.chain_finality = CHAIN_FINALITY; + + let genesis = genesis_tipset(); + persist_tipset(&genesis, &db); + let mut head = genesis.shallow_clone(); + for epoch in 1..=30 { + head = tipset_child(&head, epoch); + persist_tipset(&head, &db); + } + + let cs = ChainStore::new(db, Arc::new(chain_config), genesis).unwrap(); + cs.set_heaviest_tipset(head.shallow_clone()).unwrap(); + assert_eq!( + cs.ec_calculator_finalized_epoch(), + head.epoch() - CHAIN_FINALITY, + ); + } + #[test] fn block_validation_cache_basic() { let db = DbImpl::from(Arc::new(crate::db::MemoryDB::default())); diff --git a/src/chain/store/index.rs b/src/chain/store/index.rs index dc926291071..3862c9fa40e 100644 --- a/src/chain/store/index.rs +++ b/src/chain/store/index.rs @@ -477,10 +477,13 @@ pub mod tests { })) } - pub fn tipset_child(parent: &Tipset, epoch: ChainEpoch) -> Tipset { - // Use a static counter to give all tipsets a unique timestamp + fn next_tipset_nonce() -> u64 { static COUNTER: AtomicU64 = AtomicU64::new(0); - let n = COUNTER.fetch_add(1, Ordering::Relaxed); + COUNTER.fetch_add(1, Ordering::Relaxed) + } + + pub fn tipset_child(parent: &Tipset, epoch: ChainEpoch) -> Tipset { + let n = next_tipset_nonce(); Tipset::from(CachingBlockHeader::new(RawBlockHeader { parents: parent.key().clone(), ticket: dummy_ticket(n as u8), @@ -490,6 +493,30 @@ pub mod tests { })) } + pub fn tipset_child_with_blocks( + parent: &Tipset, + epoch: ChainEpoch, + n: usize, + state_root: Cid, + ) -> Tipset { + assert!(n > 0, "tipset must have at least one block"); + let headers: Vec<_> = (0..n) + .map(|i| { + let nonce = next_tipset_nonce(); + CachingBlockHeader::new(RawBlockHeader { + parents: parent.key().clone(), + miner_address: Address::new_id(i as u64), + ticket: dummy_ticket(nonce as u8), + epoch, + timestamp: nonce, + state_root, + ..Default::default() + }) + }) + .collect(); + Tipset::new(headers).expect("valid multi-block tipset") + } + #[test] fn get_null_tipset() { let db = Arc::new(MemoryDB::default()); diff --git a/src/message_pool/msgpool/msg_pool.rs b/src/message_pool/msgpool/msg_pool.rs index df1a3f0ee30..ffbb4cde73b 100644 --- a/src/message_pool/msgpool/msg_pool.rs +++ b/src/message_pool/msgpool/msg_pool.rs @@ -1045,6 +1045,7 @@ mod tests { db.put_cbor_default(head.block_headers().first()).unwrap(); let cs = ChainStore::new(db, cfg, genesis.block_headers().first().clone()).unwrap(); + cs.set_heaviest_tipset(head.shallow_clone()).unwrap(); // f0300 exists in lookback state (root_a) → resolves successfully. let result = Provider::resolve_to_deterministic_address_at_finality( diff --git a/src/message_pool/msgpool/provider.rs b/src/message_pool/msgpool/provider.rs index 65f20079cde..f397ae146a2 100644 --- a/src/message_pool/msgpool/provider.rs +++ b/src/message_pool/msgpool/provider.rs @@ -111,7 +111,7 @@ impl Provider for ChainStore { ) -> Result { ChainStore::resolve_to_deterministic_address_at_finality(self, addr, ts) .map(AtFinalityResolution::into_address) - .map_err(|e| Error::Other(e.to_string())) + .map_err(|e| Error::Other(format!("{e:#}"))) } fn messages_for_tipset(&self, ts: &Tipset) -> Result>, Error> { diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 512f57290ed..aad9716c20b 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -2981,8 +2981,17 @@ struct CachedReceipt { finalized: bool, } -fn is_finalized(ctx: &Ctx, head_epoch: ChainEpoch, receipt_epoch: ChainEpoch) -> bool { - receipt_epoch <= head_epoch - ctx.chain_config().policy.chain_finality +// Finality-gated caches we relaxed: ETH receipts, ID→address. +// Everything else is CID-keyed or not gated on 900. + +/// True once the EC calculator has finalized this epoch. Not F3: F3 can +/// sit ahead of the local head during catchup. +fn is_finalized(ctx: &Ctx, receipt_epoch: ChainEpoch) -> bool { + receipt_is_ec_finalized(ctx.chain_store(), receipt_epoch) +} + +fn receipt_is_ec_finalized(cs: &ChainStore, receipt_epoch: ChainEpoch) -> bool { + receipt_epoch <= cs.ec_calculator_finalized_epoch() } async fn get_eth_transaction_receipt_with_cache( @@ -3031,7 +3040,7 @@ async fn get_eth_transaction_receipt_with_cache( } let finalized = receipt .as_ref() - .is_some_and(|r| is_finalized(&ctx, head.epoch(), r.block_number.0)); + .is_some_and(|r| is_finalized(&ctx, r.block_number.0)); Ok::<_, Uncacheable>(CachedReceipt { receipt, head_key, @@ -4240,6 +4249,46 @@ mod test { } } + #[test] + fn receipt_is_finalized_at_ec_boundary_not_chain_finality() { + use crate::chain::ec_finality::calculator::DEFAULT_BLOCKS_PER_EPOCH; + use crate::chain::index::tests::{ + genesis_tipset, persist_tipset, tipset_child_with_blocks, + }; + use crate::networks::ChainConfig; + + const EPOCHS: ChainEpoch = 40; + let blocks_per_epoch = DEFAULT_BLOCKS_PER_EPOCH as usize; + let db: DbImpl = Arc::new(MemoryDB::default()).into(); + let cfg = Arc::new(ChainConfig::default()); + + let genesis = genesis_tipset(); + persist_tipset(&genesis, &db); + let state_root = *genesis.parent_state(); + + let mut head = genesis.shallow_clone(); + for epoch in 1..=EPOCHS { + head = tipset_child_with_blocks(&head, epoch, blocks_per_epoch, state_root); + persist_tipset(&head, &db); + } + + let cs = ChainStore::new(db, cfg, genesis).unwrap(); + cs.set_heaviest_tipset(head.shallow_clone()).unwrap(); + + let finalized = cs.ec_calculator_finalized_epoch(); + assert!( + head.epoch() < cs.chain_config().policy.chain_finality, + "old head-900 rule would treat no receipt on this chain as final" + ); + assert!( + finalized > 0, + "healthy chain must meet the EC guarantee (got finalized_epoch={finalized})" + ); + assert!(receipt_is_ec_finalized(&cs, finalized)); + assert!(!receipt_is_ec_finalized(&cs, finalized + 1)); + assert!(!receipt_is_ec_finalized(&cs, head.epoch())); + } + #[test] fn execution_reverted_from_receipt_decodes_reason_and_data() { // A receipt carries its return payload CBOR-wrapped. The `Error(string)` ABI decode is diff --git a/src/state_manager/address_resolution.rs b/src/state_manager/address_resolution.rs index 7656536bf4d..59e4fc61046 100644 --- a/src/state_manager/address_resolution.rs +++ b/src/state_manager/address_resolution.rs @@ -230,6 +230,7 @@ mod tests { db.put_cbor_default(head.block_headers().first()).unwrap(); let cs = ChainStore::new(db, cfg, genesis.block_headers().first().clone()).unwrap(); + cs.set_heaviest_tipset(head.shallow_clone()).unwrap(); let sm = StateManager::new(cs).unwrap(); (sm, head, bls_a, bls_b) } @@ -287,11 +288,7 @@ mod tests { #[tokio::test] async fn does_not_cache_at_exact_finality_boundary() { - // Head epoch == chain_finality: the guard is strictly `>`, so this is - // not finality-deep and the lookback degrades to resolving at the - // head's own parent state (which does contain f0300, since `root_b` - // was built on top of `root_a`). The resolution succeeds but, being - // `Unstable`, must not be cached. + // Head is exactly `ec_depth` epochs deep, so lookback does not apply. let (sm, head, bls_a, _bls_b) = setup_with_finality(2); let resolved = sm .resolve_to_deterministic_address(Address::new_id(OLD_ACTOR), &head) @@ -301,7 +298,7 @@ mod tests { assert_eq!( sm.id_to_deterministic_address_cache().unwrap().len(), 0, - "epoch == chain_finality is not finality-deep and must not be cached" + "epoch == ec_depth cannot look back and must not be cached" ); } @@ -332,4 +329,76 @@ mod tests { assert_eq!(resolved, bls_a); assert_eq!(sm.id_to_deterministic_address_cache().unwrap().len(), 0); } + + /// Healthy 40-epoch chain at 5 blocks/epoch: EC finality is shallower than + /// default `chain_finality` (900), so only the calculator can make a + /// resolution cacheable. + fn setup_healthy_ec_chain() -> (StateManager, Tipset, Address) { + use crate::chain::ec_finality::calculator::DEFAULT_BLOCKS_PER_EPOCH; + use crate::chain::store::index::tests::{persist_tipset, tipset_child_with_blocks}; + + const EPOCHS: ChainEpoch = 40; + let blocks_per_epoch = DEFAULT_BLOCKS_PER_EPOCH as usize; + + let db: DbImpl = Arc::new(MemoryDB::default()).into(); + let cfg = Arc::new(ChainConfig::default()); + + let bls = Address::new_bls(&[8u8; 48]).unwrap(); + let mut st = StateTree::new(&db, StateTreeVersion::V5).unwrap(); + st.set_actor( + &Address::new_id(OLD_ACTOR), + ActorState::new_empty(Cid::default(), Some(bls)), + ) + .unwrap(); + let root = st.flush().unwrap(); + + let genesis = Tipset::from(CachingBlockHeader::new(RawBlockHeader { + ticket: dummy_ticket(0), + state_root: root, + timestamp: 1, + ..Default::default() + })); + persist_tipset(&genesis, &db); + + let mut head = genesis.shallow_clone(); + for epoch in 1..=EPOCHS { + head = tipset_child_with_blocks(&head, epoch, blocks_per_epoch, root); + persist_tipset(&head, &db); + } + + let cs = ChainStore::new(db, cfg, genesis.block_headers().first().clone()).unwrap(); + cs.set_heaviest_tipset(head.shallow_clone()).unwrap(); + + let finalized = cs.ec_calculator_finalized_epoch(); + assert!( + finalized > 0, + "healthy chain must meet the EC guarantee (got finalized_epoch={finalized})" + ); + assert!( + head.epoch() < cs.chain_config().policy.chain_finality, + "head must be younger than chain_finality so only EC can make this cacheable (head={} chain_finality={})", + head.epoch(), + cs.chain_config().policy.chain_finality, + ); + + let sm = StateManager::new(cs).unwrap(); + (sm, head, bls) + } + + #[tokio::test] + async fn caches_when_ec_finality_is_shallower_than_chain_finality() { + let (sm, head, bls) = setup_healthy_ec_chain(); + let resolved = sm + .resolve_to_deterministic_address(Address::new_id(OLD_ACTOR), &head) + .await + .unwrap(); + assert_eq!(resolved, bls); + assert_eq!( + sm.id_to_deterministic_address_cache() + .unwrap() + .get(&OLD_ACTOR), + Some(bls), + "resolution at the EC lookback must be cacheable under default chain_finality" + ); + } }