diff --git a/.github/workflows/forest.yml b/.github/workflows/forest.yml index a0a06721796..68fae497e52 100644 --- a/.github/workflows/forest.yml +++ b/.github/workflows/forest.yml @@ -512,6 +512,9 @@ jobs: forest-dev devnet eth-gas forest-dev devnet eth-skip-sender forest-dev devnet state-call + if [[ "${{ matrix.dir }}" == "scripts/devnet-forest-miner" ]]; then + forest-dev devnet miner-onboarding + fi timeout-minutes: ${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }} - name: Dump docker logs if: always() diff --git a/docs/docs/users/reference/cli.sh b/docs/docs/users/reference/cli.sh index 5435f7a29dc..885d2c1a1b7 100755 --- a/docs/docs/users/reference/cli.sh +++ b/docs/docs/users/reference/cli.sh @@ -219,3 +219,4 @@ generate_markdown_section "forest-dev" "devnet" generate_markdown_section "forest-dev" "devnet eth-gas" generate_markdown_section "forest-dev" "devnet eth-skip-sender" generate_markdown_section "forest-dev" "devnet state-call" +generate_markdown_section "forest-dev" "devnet miner-onboarding" diff --git a/scripts/devnet-forest-miner/README.md b/scripts/devnet-forest-miner/README.md index ff9ce176fb2..ed78a7ed4ec 100644 --- a/scripts/devnet-forest-miner/README.md +++ b/scripts/devnet-forest-miner/README.md @@ -16,3 +16,12 @@ validates and follows. ```shell mise run test:devnet:forest-miner ``` + +On this devnet the runner also runs `forest-dev devnet miner-onboarding`: + +- `create_miner` — `lotus-miner init` through Forest; Forest and Lotus must agree on creation + deposit and the new miner's info. +- `pledge_sector` — real 2 KiB CC seal to `Proving`; wrong Forest randomness/deposit/pledge fails + on-chain prove-commit. Sector info must then match on Forest and Lotus. + +Refuses `../devnet` (Lotus Miner would talk to Lotus, not Forest). diff --git a/scripts/devnet/run_integration_tests.sh b/scripts/devnet/run_integration_tests.sh index c39aad7a91f..cee35528aeb 100755 --- a/scripts/devnet/run_integration_tests.sh +++ b/scripts/devnet/run_integration_tests.sh @@ -23,3 +23,8 @@ forest-dev tests wallet forest-dev devnet eth-gas forest-dev devnet eth-skip-sender forest-dev devnet state-call + +# Only the forest-produced devnet runs Lotus Miner against Forest's RPC. +if [[ "$(basename "${DEVNET_DIR}")" == "devnet-forest-miner" ]]; then + forest-dev devnet miner-onboarding +fi diff --git a/scripts/devnet/test_harness.sh b/scripts/devnet/test_harness.sh index c134f2bd646..12882f12369 100644 --- a/scripts/devnet/test_harness.sh +++ b/scripts/devnet/test_harness.sh @@ -1,6 +1,6 @@ #!/bin/bash # Sourced (not executed) helpers for the devnet test suites (wallet, mpool, -# eth-gas, eth-skip-sender) on the docker devnet. Run after the devnet is up (`setup.sh`) and synced (`check.sh`). +# eth-gas, eth-skip-sender, miner-onboarding) on the docker devnet. Run after the devnet is up (`setup.sh`) and synced (`check.sh`). # # The genesis key is the Lotus miner's default wallet, so using it as the test # sender causes nonce contention. We fund a dedicated wallet instead. diff --git a/src/dev/subcommands/devnet_cmd.rs b/src/dev/subcommands/devnet_cmd.rs index bf237aa8fb2..9293f4e64c2 100644 --- a/src/dev/subcommands/devnet_cmd.rs +++ b/src/dev/subcommands/devnet_cmd.rs @@ -8,6 +8,7 @@ mod eth_gas; mod eth_skip_sender; +mod miner_onboarding; mod state_call; use crate::dev::subcommands::tests_cmd::helpers::{docker, forest_client, lotus_client}; @@ -19,6 +20,7 @@ use anyhow::{Context as _, ensure}; pub enum DevnetCommand { EthGas(eth_gas::EthGasTestCommand), EthSkipSender(eth_skip_sender::EthSkipSenderTestCommand), + MinerOnboarding(miner_onboarding::MinerOnboardingTestCommand), StateCall(state_call::StateCallTestCommand), } @@ -28,6 +30,7 @@ impl DevnetCommand { match self { Self::EthGas(cmd) => cmd.run().await, Self::EthSkipSender(cmd) => cmd.run().await, + Self::MinerOnboarding(cmd) => cmd.run().await, Self::StateCall(cmd) => cmd.run().await, } } diff --git a/src/dev/subcommands/devnet_cmd/miner_onboarding.rs b/src/dev/subcommands/devnet_cmd/miner_onboarding.rs new file mode 100644 index 00000000000..2358fc00aa4 --- /dev/null +++ b/src/dev/subcommands/devnet_cmd/miner_onboarding.rs @@ -0,0 +1,260 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! Lotus Miner creating a miner and pledging a sector through Forest's RPC. Mirrors Lotus +//! `testNonGenesisMiner` and `TestPledgeSectors` (single sector, real 2 KiB proofs). + +use crate::dev::subcommands::tests_cmd::helpers::*; +use crate::rpc::Client; +use crate::rpc::prelude::*; +use crate::rpc::types::{ApiTipsetKey, SectorOnChainInfo}; +use crate::shim::actors::miner::MinerInfo; +use crate::shim::address::Address; +use crate::shim::sector::SectorSize; +use ahash::HashSet; +use anyhow::{Context as _, bail, ensure}; +use libtest_mimic::{Arguments, Failed, Trial}; +use std::str::FromStr as _; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const FOREST_MINER_PROJECT: &str = "devnet-forest-miner"; +/// Poll once per block so batch flushes land before the next status check. +const BLOCK_TIME: Duration = Duration::from_secs(4); +/// 2 KiB seal needs several minutes (seed wait + message confidence). +const SEALING_TIMEOUT: Duration = Duration::from_secs(600); + +/// Miner creation and sector pledge tests with Lotus Miner on Forest +#[derive(Debug, clap::Args)] +pub struct MinerOnboardingTestCommand {} + +impl MinerOnboardingTestCommand { + pub async fn run(self) -> anyhow::Result<()> { + ensure_forest_miner_devnet()?; + let args = Arguments { + test_threads: Some(1), + ..Default::default() + }; + libtest_mimic::run(&args, tests()).exit(); + } +} + +fn tests() -> Vec { + fn trial(name: &'static str, body: fn() -> anyhow::Result<()>) -> Trial { + Trial::test(name, move || { + body().map_err(|e| Failed::from(format!("{e:?}"))) + }) + } + + vec![ + trial("create_miner", || block_on(create_miner())), + trial("pledge_sector", || block_on(pledge_sector())), + ] +} + +/// On `scripts/devnet` Lotus Miner talks to Lotus, so this suite would pass without hitting Forest. +fn ensure_forest_miner_devnet() -> anyhow::Result<()> { + let project = docker(&[ + "inspect", + "-f", + r#"{{index .Config.Labels "com.docker.compose.project"}}"#, + "lotus-miner", + ]) + .context("could not inspect the `lotus-miner` container; is the Forest-miner devnet up?")?; + ensure!( + project == FOREST_MINER_PROJECT, + "`lotus-miner` belongs to compose project `{project}`, not `{FOREST_MINER_PROJECT}`; \ + this suite needs the Forest-miner devnet (scripts/devnet-forest-miner)" + ); + Ok(()) +} + +fn genesis_miner() -> anyhow::Result
{ + let raw = std::env::var("MINER_ACTOR_ADDRESS") + .context("MINER_ACTOR_ADDRESS not set; source the devnet test harness")?; + Address::from_str(&raw).with_context(|| format!("invalid MINER_ACTOR_ADDRESS `{raw}`")) +} + +async fn miner_info(client: &Client, miner: Address) -> anyhow::Result { + Ok(client + .call(StateMinerInfo::request((miner, ApiTipsetKey(None)))?) + .await?) +} + +async fn list_miners(client: &Client) -> anyhow::Result> { + Ok(client + .call(StateListMiners::request((ApiTipsetKey(None),))?) + .await? + .into_iter() + .collect()) +} + +async fn sector_info( + client: &Client, + miner: Address, + sector: u64, +) -> anyhow::Result> { + Ok(client + .call(StateSectorGetInfo::request(( + miner, + sector, + ApiTipsetKey(None), + ))?) + .await?) +} + +async fn create_miner() -> anyhow::Result<()> { + let forest = forest_client()?; + let lotus = lotus_client()?; + let genesis_owner = miner_info(&forest, genesis_miner()?).await?.owner; + let owner_key = forest + .call(StateAccountKey::request(( + genesis_owner, + ApiTipsetKey(None), + ))?) + .await?; + + // Lotus trails Forest; query both at Lotus's head so the tipset exists on Forest. + let lotus_head = lotus.call(ChainHead::request(())?).await?; + let at_lotus_head = ApiTipsetKey(Some(lotus_head.key().clone())); + let forest_deposit = forest + .call(StateMinerCreationDeposit::request( + (at_lotus_head.clone(),), + )?) + .await?; + let lotus_deposit = lotus + .call(StateMinerCreationDeposit::request((at_lotus_head,))?) + .await?; + ensure!( + forest_deposit == lotus_deposit, + "StateMinerCreationDeposit at epoch {}: forest {forest_deposit}, lotus {lotus_deposit}", + lotus_head.epoch() + ); + + let before = list_miners(&forest).await?; + let repo = format!( + "/tmp/forest-onboarding-{}", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() + ); + let init = init_miner(&repo, owner_key); + let _ = docker(&["exec", "lotus-miner", "rm", "-rf", &repo]); + init?; + + let created = poll("Forest to list the new miner", || async { + let created: Vec
= list_miners(&forest) + .await? + .difference(&before) + .copied() + .collect(); + Ok((!created.is_empty()).then_some(created)) + }) + .await?; + let [miner] = created.as_slice() else { + bail!("expected exactly one new miner after `lotus-miner init`, found {created:?}"); + }; + + let info = miner_info(&forest, *miner).await?; + ensure!( + info.owner == genesis_owner && info.worker == genesis_owner, + "miner {miner}: owner {} and worker {} should both be {genesis_owner}", + info.owner, + info.worker + ); + ensure!( + info.sector_size == SectorSize::_2KiB, + "miner {miner}: sector size {:?}, expected 2 KiB", + info.sector_size + ); + ensure!( + !info.peer_id.is_empty(), + "miner {miner}: no peer ID recorded" + ); + + poll_until_actor_on("lotus", *miner, lotus_client).await?; + let lotus_info = miner_info(&lotus, *miner).await?; + ensure!( + lotus_info == info, + "StateMinerInfo for {miner} differs:\nforest: {info:?}\nlotus: {lotus_info:?}" + ); + Ok(()) +} + +fn init_miner(repo: &str, owner: Address) -> anyhow::Result<()> { + let port = std::env::var("FOREST_RPC_PORT") + .context("FOREST_RPC_PORT not set; source the devnet test harness")?; + let repo_env = format!("LOTUS_MINER_PATH={repo}"); + let api_env = format!( + "FULLNODE_API_INFO={}:/dns/forest/tcp/{port}/http", + forest_api_token()? + ); + let owner_flag = format!("--owner={owner}"); + docker(&[ + "exec", + "-e", + &repo_env, + "-e", + &api_env, + "lotus-miner", + "lotus-miner", + "init", + &owner_flag, + "--sector-size=2KiB", + "--no-local-storage", + "--nosync", + ]) + .context("`lotus-miner init` against Forest failed")?; + Ok(()) +} + +/// Pledges one CC sector; the chain verifies the seal proof on prove-commit. +async fn pledge_sector() -> anyhow::Result<()> { + let miner = genesis_miner()?; + let out = lotus_miner_exec(&["sectors", "pledge"])?; + let sector: u64 = out + .lines() + .find_map(|l| l.trim().strip_prefix("Created CC sector:")) + .with_context(|| format!("no `Created CC sector:` in pledge output:\n{out}"))? + .trim() + .parse() + .context("parsing the pledged sector number")?; + + wait_until_proving(sector).await?; + + let forest_info = sector_info(&forest_client()?, miner, sector) + .await? + .with_context(|| format!("sector {sector} is Proving, but Forest has no info for it"))?; + let lotus_info = poll( + &format!("lotus StateSectorGetInfo {miner} {sector}"), + || async { sector_info(&lotus_client()?, miner, sector).await }, + ) + .await?; + ensure!( + forest_info == lotus_info, + "StateSectorGetInfo for sector {sector} differs:\nforest: {forest_info:?}\nlotus: {lotus_info:?}" + ); + Ok(()) +} + +/// Flush precommit/commit batches each attempt: Lotus Miner waits up to `24h` before publishing. +async fn wait_until_proving(sector: u64) -> anyhow::Result<()> { + let sector_arg = sector.to_string(); + let label = format!("sector {sector} to reach Proving"); + poll_every(&label, BLOCK_TIME, SEALING_TIMEOUT, || async { + // Non-zero exit with "no sectors to publish" is expected while nothing is batched. + let _ = lotus_miner_exec(&["sectors", "batching", "precommit", "--publish-now"]); + let _ = lotus_miner_exec(&["sectors", "batching", "commit", "--publish-now"]); + let status = lotus_miner_exec(&["sectors", "status", §or_arg])?; + let state = status + .lines() + .find_map(|l| l.trim().strip_prefix("Status:")) + .map(str::trim) + .with_context(|| format!("no `Status:` in sector status output:\n{status}"))?; + eprintln!("sector {sector} state: {state}"); + if state.contains("Fail") { + let log = lotus_miner_exec(&["sectors", "status", "--log", §or_arg]) + .unwrap_or_else(|e| format!("{e:#}")); + bail!("sector {sector} entered failed state {state}:\n{log}"); + } + Ok(matches!(state, "Proving" | "Available").then_some(())) + }) + .await +} diff --git a/src/dev/subcommands/tests_cmd/helpers.rs b/src/dev/subcommands/tests_cmd/helpers.rs index 8bceea601c6..d659b04a5ae 100644 --- a/src/dev/subcommands/tests_cmd/helpers.rs +++ b/src/dev/subcommands/tests_cmd/helpers.rs @@ -210,7 +210,21 @@ const RPC_RETRY_DELAY: Duration = Duration::from_secs(15); /// Poll until `try_check` returns `Some` or [`POLL_TIMEOUT`] elapses, sleeping /// [`POLL_WAIT_TIME`] between attempts. -pub async fn poll(label: &str, mut try_check: F) -> anyhow::Result +pub async fn poll(label: &str, try_check: F) -> anyhow::Result +where + F: FnMut() -> Fut, + Fut: Future>>, +{ + poll_every(label, POLL_WAIT_TIME, POLL_TIMEOUT, try_check).await +} + +/// [`poll`] with a caller-chosen `interval` and `timeout`, for conditions that take many blocks. +pub async fn poll_every( + label: &str, + interval: Duration, + timeout: Duration, + mut try_check: F, +) -> anyhow::Result where F: FnMut() -> Fut, Fut: Future>>, @@ -223,11 +237,11 @@ where if let Some(value) = try_check().await? { return Ok(value); } - if started.elapsed() >= POLL_TIMEOUT { - bail!("Timed out waiting for {label} after {POLL_TIMEOUT:?}"); + if started.elapsed() >= timeout { + bail!("Timed out waiting for {label} after {timeout:?}"); } - let remaining = POLL_TIMEOUT.saturating_sub(started.elapsed()); - tokio::time::sleep(POLL_WAIT_TIME.min(remaining)).await; + let remaining = timeout.saturating_sub(started.elapsed()); + tokio::time::sleep(interval.min(remaining)).await; } } @@ -403,6 +417,11 @@ fn api() -> anyhow::Result<&'static (String, String)> { .map_err(|e| anyhow::anyhow!("FULLNODE_API_INFO unavailable: {e}")) } +/// Forest's API token, from `FULLNODE_API_INFO`. +pub fn forest_api_token() -> anyhow::Result<&'static str> { + Ok(api()?.0.as_str()) +} + /// Typed client for the Lotus node on the docker devnet, whose read methods need no token. pub fn lotus_client() -> anyhow::Result { let port = std::env::var("LOTUS_RPC_PORT") @@ -428,6 +447,13 @@ pub fn lotus_exec(args: &[&str]) -> anyhow::Result { docker(&full) } +/// Runs `lotus-miner ` inside the `lotus-miner` container on the docker devnet. +pub fn lotus_miner_exec(args: &[&str]) -> anyhow::Result { + let mut full = vec!["exec", "lotus-miner", "lotus-miner"]; + full.extend_from_slice(args); + docker(&full) +} + /// POST a JSON-RPC v1 request and return the `result` field, or `None` if /// the server responded without one. pub async fn rpc_call_opt(method: &str, params: Value) -> anyhow::Result> { diff --git a/src/networks/devnet/mod.rs b/src/networks/devnet/mod.rs index 815d56a0754..e3ec8654198 100644 --- a/src/networks/devnet/mod.rs +++ b/src/networks/devnet/mod.rs @@ -31,40 +31,40 @@ pub static GENESIS_NETWORK_VERSION: LazyLock = LazyLock::new(|| /// Height epochs. /// Environment variable names follow -/// +/// . pub static HEIGHT_INFOS: LazyLock> = LazyLock::new(|| { IndexMap::from_iter([ make_height!( Breeze, - get_upgrade_height_from_env("FOREST_BREEZE_HEIGHT").unwrap_or(-50) + get_upgrade_height_from_env("FOREST_BREEZE_HEIGHT").unwrap_or(-1) ), make_height!( Smoke, - get_upgrade_height_from_env("FOREST_SMOKE_HEIGHT").unwrap_or(-2) + get_upgrade_height_from_env("FOREST_SMOKE_HEIGHT").unwrap_or(-1) ), make_height!( Ignition, - get_upgrade_height_from_env("FOREST_IGNITION_HEIGHT").unwrap_or(-3) + get_upgrade_height_from_env("FOREST_IGNITION_HEIGHT").unwrap_or(-2) ), make_height!( Refuel, - get_upgrade_height_from_env("FOREST_REFUEL_HEIGHT").unwrap_or(-4) + get_upgrade_height_from_env("FOREST_REFUEL_HEIGHT").unwrap_or(-3) ), make_height!( - Assembly, - get_upgrade_height_from_env("FOREST_ASSEMBLY_HEIGHT").unwrap_or(-5) + Tape, + get_upgrade_height_from_env("FOREST_TAPE_HEIGHT").unwrap_or(-4) ), make_height!( - Tape, - get_upgrade_height_from_env("FOREST_TAPE_HEIGHT").unwrap_or(-6) + Assembly, + get_upgrade_height_from_env("FOREST_ASSEMBLY_HEIGHT").unwrap_or(-5) ), make_height!( Liftoff, - get_upgrade_height_from_env("FOREST_LIFTOFF_HEIGHT").unwrap_or(-7) + get_upgrade_height_from_env("FOREST_LIFTOFF_HEIGHT").unwrap_or(-6) ), make_height!( Kumquat, - get_upgrade_height_from_env("FOREST_KUMQUAT_HEIGHT").unwrap_or(-8) + get_upgrade_height_from_env("FOREST_KUMQUAT_HEIGHT").unwrap_or(-7) ), make_height!( Calico, @@ -75,12 +75,12 @@ pub static HEIGHT_INFOS: LazyLock> = LazyLock::new( get_upgrade_height_from_env("FOREST_PERSIAN_HEIGHT").unwrap_or(-10) ), make_height!( - Claus, - get_upgrade_height_from_env("FOREST_CLAUS_HEIGHT").unwrap_or(-11) + Orange, + get_upgrade_height_from_env("FOREST_ORANGE_HEIGHT").unwrap_or(-11) ), make_height!( - Orange, - get_upgrade_height_from_env("FOREST_ORANGE_HEIGHT").unwrap_or(-12) + Claus, + get_upgrade_height_from_env("FOREST_CLAUS_HEIGHT").unwrap_or(-12) ), make_height!( Trust, diff --git a/src/state_manager/state_computation.rs b/src/state_manager/state_computation.rs index 414c7de26ff..e37addffcb1 100644 --- a/src/state_manager/state_computation.rs +++ b/src/state_manager/state_computation.rs @@ -556,18 +556,17 @@ impl<'a> TipsetExecutor<'a> { where F: FnMut(MessageCallbackCtx<'_>) -> anyhow::Result<()>, { - use crate::shim::clock::EPOCH_DURATION_SECONDS; - let mut parent_state = *self.tipset.parent_state(); let parent_epoch = self .chain_index .load_required_tipset(self.tipset.parents())? .epoch(); let epoch = self.tipset.epoch(); + let block_delay_secs = u64::from(self.chain_config.block_delay_secs); for epoch_i in parent_epoch..epoch { if epoch_i > parent_epoch { - let timestamp = genesis_timestamp + ((EPOCH_DURATION_SECONDS * epoch_i) as u64); + let timestamp = genesis_timestamp + (block_delay_secs * epoch_i as u64); parent_state = stacker::grow(64 << 20, || -> anyhow::Result { let mut vm = self.create_vm(parent_state, epoch_i, timestamp, null_epoch_trace)?; @@ -646,9 +645,11 @@ impl<'a> TipsetExecutor<'a> { /// messages in epoch 10, we have to run cron for epoch 9. However, running /// 'cron' requires the timestamp of the youngest block in the tipset (which /// doesn't exist because there are no blocks in the tipset). Lotus dictates that -/// the timestamp of a null tipset is `30s * epoch` after the genesis timestamp. -/// So, in the above example, if the genesis block was mined at time `X`, the -/// null tipset for epoch 9 will have timestamp `X + 30 * 9`. +/// the timestamp of a null tipset is `BlockDelaySecs * epoch` after the genesis +/// timestamp (30 seconds on mainnet/calibnet, 4 seconds on 2k/devnet). So, in +/// the above example, if the genesis block was mined at time `X` with a +/// 30-second block delay, the null tipset for epoch 9 will have timestamp +/// `X + 30 * 9`. /// /// # Migrations ///