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: 3 additions & 0 deletions .github/workflows/forest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions docs/docs/users/reference/cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
9 changes: 9 additions & 0 deletions scripts/devnet-forest-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
5 changes: 5 additions & 0 deletions scripts/devnet/run_integration_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion scripts/devnet/test_harness.sh
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/dev/subcommands/devnet_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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),
}

Expand All @@ -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,
}
}
Expand Down
260 changes: 260 additions & 0 deletions src/dev/subcommands/devnet_cmd/miner_onboarding.rs
Original file line number Diff line number Diff line change
@@ -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<Trial> {
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<Address> {
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<MinerInfo> {
Ok(client
.call(StateMinerInfo::request((miner, ApiTipsetKey(None)))?)
.await?)
}

async fn list_miners(client: &Client) -> anyhow::Result<HashSet<Address>> {
Ok(client
.call(StateListMiners::request((ApiTipsetKey(None),))?)
.await?
.into_iter()
.collect())
}

async fn sector_info(
client: &Client,
miner: Address,
sector: u64,
) -> anyhow::Result<Option<SectorOnChainInfo>> {
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<Address> = 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", &sector_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", &sector_arg])
.unwrap_or_else(|e| format!("{e:#}"));
bail!("sector {sector} entered failed state {state}:\n{log}");
}
Ok(matches!(state, "Proving" | "Available").then_some(()))
})
.await
}
36 changes: 31 additions & 5 deletions src/dev/subcommands/tests_cmd/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<F, Fut, T>(label: &str, mut try_check: F) -> anyhow::Result<T>
pub async fn poll<F, Fut, T>(label: &str, try_check: F) -> anyhow::Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = anyhow::Result<Option<T>>>,
{
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<F, Fut, T>(
label: &str,
interval: Duration,
timeout: Duration,
mut try_check: F,
) -> anyhow::Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = anyhow::Result<Option<T>>>,
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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<crate::rpc::Client> {
let port = std::env::var("LOTUS_RPC_PORT")
Expand All @@ -428,6 +447,13 @@ pub fn lotus_exec(args: &[&str]) -> anyhow::Result<String> {
docker(&full)
}

/// Runs `lotus-miner <args>` inside the `lotus-miner` container on the docker devnet.
pub fn lotus_miner_exec(args: &[&str]) -> anyhow::Result<String> {
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<Option<Value>> {
Expand Down
Loading
Loading