Skip to content
Open
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
3 changes: 2 additions & 1 deletion .config/forest.dic
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
295
296
ABI
Algorand/M
API's
Expand Down Expand Up @@ -36,6 +36,7 @@ bootstrapper/S
BuildKit
butterflynet
bytecode
cacheable
Caddy
calibnet
calldata
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment on lines +36 to +37

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- [#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.

This is not a user-facing change but an internal detail.

- [#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
Expand Down
91 changes: 69 additions & 22 deletions src/chain/store/chain_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 <https://github.com/filecoin-project/lotus/blob/v1.35.1/chain/stmgr/stmgr.go#L361>
/// 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,
Expand All @@ -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"),
}
Expand Down Expand Up @@ -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()));
Expand Down
33 changes: 30 additions & 3 deletions src/chain/store/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: collect_vec

Tipset::new(headers).expect("valid multi-block tipset")
}

#[test]
fn get_null_tipset() {
let db = Arc::new(MemoryDB::default());
Expand Down
1 change: 1 addition & 0 deletions src/message_pool/msgpool/msg_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/message_pool/msgpool/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ impl Provider for ChainStore {
) -> Result<Address, Error> {
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<Arc<Vec<ChainMessage>>, Error> {
Expand Down
55 changes: 52 additions & 3 deletions src/rpc/methods/eth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
81 changes: 75 additions & 6 deletions src/state_manager/address_resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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"
);
}

Expand Down Expand Up @@ -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})"
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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"
);
}
}
Loading