Skip to content
Draft
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
26 changes: 16 additions & 10 deletions src/networks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,20 +512,26 @@ impl ChainConfig {
.unwrap_or(0)
}

/// Returns true if executing between `parent` and `height` (exclusive of `height`) would
/// cross an expensive state migration, as registered in
/// [`crate::state_migration::get_migrations`].
pub fn has_expensive_fork_between(&self, parent: ChainEpoch, height: ChainEpoch) -> bool {
/// Returns the lowest expensive state migration epoch in `[parent, height)` if one exists.
pub fn expensive_fork_between(
&self,
parent: ChainEpoch,
height: ChainEpoch,
) -> Option<ChainEpoch> {
if parent >= height {
return false;
return None;
}
crate::state_migration::get_migrations::<crate::db::DbImpl>(&self.network)
.iter()
.any(|(h, _)| {
self.height_infos
.get(h)
.is_some_and(|info| info.epoch >= parent && info.epoch < height)
})
.filter_map(|(h, _)| self.height_infos.get(h).map(|info| info.epoch))
.filter(|epoch| *epoch >= parent && *epoch < height)
.min()
}

/// Reports whether an expensive migration is triggered in the half-open epoch interval
/// `[parent, height)`.
pub fn has_expensive_fork_between(&self, parent: ChainEpoch, height: ChainEpoch) -> bool {
self.expensive_fork_between(parent, height).is_some()
}

pub async fn genesis_bytes<DB: SettingsStore>(
Expand Down
12 changes: 12 additions & 0 deletions src/rpc/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ pub(crate) mod implementation_defined_errors {
/// node. Note that it's not the same as not found, as we are explicitly not supporting it,
/// e.g., because it's deprecated or Lotus is doing the same.
pub(crate) const UNSUPPORTED_METHOD: i32 = -32001;
/// EIP-1474 "resource unavailable": explicit call targets a block whose state cannot be
/// served without running an expensive migration on demand.
pub(crate) const EXPENSIVE_FORK_CODE: i32 = -32002;
}

impl ServerError {
Expand Down Expand Up @@ -98,6 +101,15 @@ impl From<anyhow::Error> for ServerError {
if let Some(eth_error) = error.downcast_ref::<EthErrors>() {
return eth_error.clone().into();
}
if let Some(sm_error @ crate::state_manager::Error::ExpensiveFork { epoch }) =
error.downcast_ref::<crate::state_manager::Error>()
{
return Self::new(
implementation_defined_errors::EXPENSIVE_FORK_CODE,
sm_error.to_string(),
Some(serde_json::Value::from(*epoch)),
);
}

// Default fallback, not using `format!("{e:#}")` here to match Lotus error
Self::internal_error(error.to_string(), None)
Expand Down
65 changes: 36 additions & 29 deletions src/rpc/methods/eth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1854,14 +1854,11 @@ async fn apply_message(
) -> Result<ApiInvocResult, Error> {
if let Some(ts) = &tipset
&& ts.epoch() > 0
{
let parent = ctx.chain_index().load_required_tipset(ts.parents())?;
if ctx
&& ctx
.chain_config()
.has_expensive_fork_between(parent.epoch(), ts.epoch() + 1)
{
return Err(crate::state_manager::Error::ExpensiveFork.into());
}
.has_expensive_fork_between(ts.epoch(), ts.epoch() + 1)
{
return Err(crate::state_manager::Error::ExpensiveFork { epoch: ts.epoch() }.into());
}

let (invoc_res, _) = ctx
Expand Down Expand Up @@ -2182,19 +2179,24 @@ async fn eth_get_code(
..Default::default()
};

let api_invoc_result = 'invoc: {
for ts in ts.shallow_clone().chain(ctx.db()) {
match ctx
.state_manager
.call_on_state(state_root, &message, Some(ts))
{
Ok(res) => {
break 'invoc res;
}
Err(e) => tracing::warn!(%e),
// Rewind ts to escape the fork guard, but keep state_root fixed to the requested epoch: the
// result comes from state_root (ts only supplies execution context), so recomputing it for the
// parent would read an earlier epoch's bytecode.
let mut ts = ts.shallow_clone();
let api_invoc_result = loop {
match ctx
.state_manager
.call_on_state(state_root, &message, Some(ts.shallow_clone()))
{
Ok(res) => break res,
Err(crate::state_manager::Error::ExpensiveFork { .. }) => {
ts = ctx
.chain_index()
.load_required_tipset(ts.parents())
.map_err(|e| anyhow::anyhow!("getting parent tipset: {e}"))?;
}
Err(e) => return Err(e.into()),
}
return Err(anyhow::anyhow!("Call failed").into());
};
let Some(msg_rct) = api_invoc_result.msg_rct else {
return Err(anyhow::anyhow!("no message receipt").into());
Expand Down Expand Up @@ -2275,19 +2277,24 @@ async fn get_storage_at(
params,
..Default::default()
};
let api_invoc_result = 'invoc: {
for ts in ts.chain(ctx.db()) {
match ctx
.state_manager
.call_on_state(state_root, &message, Some(ts))
{
Ok(res) => {
break 'invoc res;
}
Err(e) => tracing::warn!(%e),
// Rewind ts to escape the fork guard, but keep state_root fixed to the requested epoch: the
// result comes from state_root (ts only supplies execution context), so recomputing it for the
// parent would read an earlier epoch's storage.
let mut ts = ts;
let api_invoc_result = loop {
match ctx
.state_manager
.call_on_state(state_root, &message, Some(ts.shallow_clone()))
{
Ok(res) => break res,
Err(crate::state_manager::Error::ExpensiveFork { .. }) => {
ts = ctx
.chain_index()
.load_required_tipset(ts.parents())
.map_err(|e| anyhow::anyhow!("getting parent tipset: {e}"))?;
}
Err(e) => return Err(e.into()),
}
return Err(anyhow::anyhow!("Call failed").into());
};
let Some(msg_rct) = api_invoc_result.msg_rct else {
return Err(anyhow::anyhow!("no message receipt").into());
Expand Down
10 changes: 4 additions & 6 deletions src/rpc/methods/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,14 @@ impl StateCall {
.chain_store()
.load_required_tipset_or_heaviest(&tsk)?;

// Match Lotus' `StateCall` behavior: if the call refuses due to an expensive
// state fork between the parent and the target tipset, walk back to the parent
// tipset and retry. This loop terminates when the call returns a non-`ExpensiveFork`
// result (success or different error), or when we fail to load the parent tipset
// (e.g. we walked back past genesis).
// Parent-state `call` refuses when a migration spans the parent→tipset window; walk back
// to the parent tipset and retry. This does not serve U+1 at the requested tipset (unlike
// `eth_call`, which uses explicit tipset state).
//
// See: <https://github.com/filecoin-project/lotus/blob/797feebc63bfbd4fdfb742b674c97bfb7846cccb/node/impl/full/state.go#L147>
loop {
match state_manager.call(message, Some(tipset.shallow_clone())) {
Err(crate::state_manager::Error::ExpensiveFork) => {
Err(crate::state_manager::Error::ExpensiveFork { .. }) => {
tipset = state_manager
.chain_index()
.load_required_tipset(tipset.parents())
Expand Down
7 changes: 5 additions & 2 deletions src/state_manager/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

use std::fmt::{Debug, Display};

use crate::shim::clock::ChainEpoch;
use thiserror::Error;
use tokio::task::JoinError;

Expand All @@ -13,8 +14,10 @@ pub enum Error {
#[error("{0}")]
State(String),
/// Refusing explicit call due to an expensive state migration at the requested epoch.
#[error("refusing explicit call due to state fork at epoch")]
ExpensiveFork,
#[error(
"required historical state unavailable: refusing explicit call due to state fork at epoch {epoch}"
)]
ExpensiveFork { epoch: ChainEpoch },
/// Other state manager error
#[error("{0}")]
Other(String),
Expand Down
20 changes: 14 additions & 6 deletions src/state_manager/message_simulation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,20 @@ impl StateManager {

let tipset = if let Some(ts) = tipset {
if ts.epoch() > 0 {
let parent = self
.chain_index()
.load_required_tipset(ts.parents())
.map_err(Error::other)?;
if chain_config.has_expensive_fork_between(parent.epoch(), ts.epoch() + 1) {
return Err(Error::ExpensiveFork);
// Explicit-state calls already hold every fork below the tipset epoch, so only a
// migration at the tipset epoch itself is refused. Parent-state calls (no explicit
// state) lag a tipset, so a migration at the parent epoch must also be refused.
let (fork_floor, fork_height) = if state_cid.is_some() {
(ts.epoch(), ts.epoch() + 1)
} else {
let parent = self
.chain_index()
.load_required_tipset(ts.parents())
.map_err(Error::other)?;
(parent.epoch(), ts.epoch() + 1)
};
if let Some(epoch) = chain_config.expensive_fork_between(fork_floor, fork_height) {
return Err(Error::ExpensiveFork { epoch });
}
}
ts
Expand Down
Loading