From 39c6d2f51ee55370e5fd7bceedc84919fcca4bbd Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Fri, 4 Sep 2026 19:54:51 +0530 Subject: [PATCH 1/9] feat: add the market state migration from actors v18 to v19 --- .../type_migrations/market/mod.rs | 1 + .../market/state_v18_to_v19.rs | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/state_migration/type_migrations/market/state_v18_to_v19.rs diff --git a/src/state_migration/type_migrations/market/mod.rs b/src/state_migration/type_migrations/market/mod.rs index e291c7b1dfeb..e4378fb0ebc5 100644 --- a/src/state_migration/type_migrations/market/mod.rs +++ b/src/state_migration/type_migrations/market/mod.rs @@ -1,4 +1,5 @@ // Copyright 2019-2026 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT +mod state_v18_to_v19; mod state_v8_to_v9; diff --git a/src/state_migration/type_migrations/market/state_v18_to_v19.rs b/src/state_migration/type_migrations/market/state_v18_to_v19.rs new file mode 100644 index 000000000000..193f7b2a7cdc --- /dev/null +++ b/src/state_migration/type_migrations/market/state_v18_to_v19.rs @@ -0,0 +1,28 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +use crate::state_migration::common::{TypeMigration, TypeMigrator}; +use fil_actor_market_state::{v18::State as MarketStateV18, v19::State as MarketStateV19}; +use fvm_ipld_blockstore::Blockstore; + +impl TypeMigration for TypeMigrator { + fn migrate_type(from: MarketStateV18, _: &impl Blockstore) -> anyhow::Result { + // FIP-0118 drops `pending_deal_allocation_ids`: verified allocations no longer take part + // in deal activation, so nothing reads them again. + // https://github.com/filecoin-project/go-state-types/blob/6cb27cf2e8be76d9b20f0d58d6d580cd99e31ce6/builtin/v19/migration/market.go#L33-L49 + Ok(MarketStateV19 { + proposals: from.proposals, + states: from.states, + pending_proposals: from.pending_proposals, + escrow_table: from.escrow_table, + locked_table: from.locked_table, + next_id: from.next_id, + deal_ops_by_epoch: from.deal_ops_by_epoch, + last_cron: from.last_cron, + total_client_locked_collateral: from.total_client_locked_collateral, + total_provider_locked_collateral: from.total_provider_locked_collateral, + total_client_storage_fee: from.total_client_storage_fee, + provider_sectors: from.provider_sectors, + }) + } +} From 3f827f9edcfa688b4f3de56ed0d8184d1cb7a7a8 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Fri, 4 Sep 2026 19:55:54 +0530 Subject: [PATCH 2/9] feat: add the Solstice reward bootstrap params to ChainConfig --- .config/forest.dic | 5 ++- src/networks/mod.rs | 97 ++++++++++++++++++++++++++++++++++++++++++++- src/shim/clock.rs | 5 +-- 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/.config/forest.dic b/.config/forest.dic index 8e86dfc8d97f..7e1b5c203ec5 100644 --- a/.config/forest.dic +++ b/.config/forest.dic @@ -1,4 +1,4 @@ -296 +299 ABI Algorand/M API's @@ -235,6 +235,7 @@ signable Skellam skippable Sqlx +SRA statediff stateful stateroots @@ -244,6 +245,7 @@ struct/SM subcall/S subcommand/S submodule/S +SWA swappiness synchronizer syscall/S @@ -254,6 +256,7 @@ teardown Terraform testnet TiB +timelock/S tipset/SM tipsetkey/S TLS diff --git a/src/networks/mod.rs b/src/networks/mod.rs index 1f145317a05c..3c940abde240 100644 --- a/src/networks/mod.rs +++ b/src/networks/mod.rs @@ -16,7 +16,8 @@ use crate::db::SettingsStore; use crate::eth::EthChainId; use crate::prelude::*; use crate::shim::{ - clock::{ChainEpoch, EPOCH_DURATION_SECONDS, EPOCHS_IN_DAY}, + address::Address, + clock::{ChainEpoch, EPOCH_DURATION_SECONDS, EPOCHS_IN_DAY, EPOCHS_IN_HOUR}, econ::TokenAmount, machine::BuiltinActorManifest, runtime::Policy, @@ -251,6 +252,56 @@ struct DrandPoint<'a> { pub config: &'a LazyLock>, } +/// A clamped linear stream weight in `DENOM` fixed point, without its slope and start epoch. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Copy)] +#[cfg_attr(test, derive(derive_quickcheck_arbitrary::Arbitrary))] +pub struct SolsticeRewardWeightParams { + pub v_start: u64, + pub floor: u64, + pub cap: u64, +} + +/// Lotus `solsticeRewardWeightPercent`. +const SOLSTICE_REWARD_WEIGHT_PERCENT: u64 = fil_actor_reward_state::v19::DENOM / 100; + +/// FIP-0118 bootstrap weights, the same on every network: the consensus stream starts at 95% of +/// the block reward and ramps down to 50%; the service stream starts at 5% and ramps up to 10%. +const SOLSTICE_CONSENSUS_WEIGHT: SolsticeRewardWeightParams = SolsticeRewardWeightParams { + v_start: 95 * SOLSTICE_REWARD_WEIGHT_PERCENT, + floor: 50 * SOLSTICE_REWARD_WEIGHT_PERCENT, + cap: 95 * SOLSTICE_REWARD_WEIGHT_PERCENT, +}; +const SOLSTICE_SERVICE_WEIGHT: SolsticeRewardWeightParams = SolsticeRewardWeightParams { + v_start: 5 * SOLSTICE_REWARD_WEIGHT_PERCENT, + floor: 5 * SOLSTICE_REWARD_WEIGHT_PERCENT, + cap: 10 * SOLSTICE_REWARD_WEIGHT_PERCENT, +}; + +/// FIP-0118 reward actor bootstrap installed by the Solstice (NV29) state migration. +/// +/// Mirrors Lotus `SolsticeRewardBootstrapParams` field for field, with the per-network values +/// taken from the `params_.go` files at the same commit: +/// +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +#[cfg_attr(test, derive(derive_quickcheck_arbitrary::Arbitrary))] +pub struct SolsticeRewardBootstrapParams { + /// Delay before a stream weight authority (SWA) write takes effect. + pub swa_timelock_epochs: ChainEpoch, + /// Epochs over which the consensus stream weight ramps down from its start to its floor. + /// Zero installs the consensus stream alone at constant `DENOM` (no service stream). + pub consensus_weight_ramp_duration_epochs: ChainEpoch, + /// Weight of the stream paid to block producers. + pub consensus_weight: SolsticeRewardWeightParams, + /// Weight of the stream paid to the orchestrator. + pub service_weight: SolsticeRewardWeightParams, + /// Stream weight authority contract. `None` until it is deployed and has an `f0` address. + pub swa_actor: Option
, + /// Service reward authority contract, the writer of the service stream's shares. + pub sra_actor: Option
, + /// Sole initial recipient of the service stream. + pub initial_orchestrator: Option
, +} + /// Defines all network configuration parameters. #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] #[cfg_attr(test, derive(derive_quickcheck_arbitrary::Arbitrary))] @@ -279,6 +330,7 @@ pub struct ChainConfig { pub fip0081_ramp_duration_epochs: u64, // See FIP-0100 and https://github.com/filecoin-project/lotus/pull/12938 for why this exists pub upgrade_teep_initial_fil_reserved: Option, + pub solstice_reward_bootstrap: SolsticeRewardBootstrapParams, pub f3_enabled: bool, // F3Consensus set whether F3 should checkpoint tipsets finalized by F3. This flag has no effect if F3 is not enabled. pub f3_consensus: bool, @@ -308,6 +360,17 @@ impl ChainConfig { // 1 year on mainnet fip0081_ramp_duration_epochs: 365 * EPOCHS_IN_DAY as u64, upgrade_teep_initial_fil_reserved: None, + // Taken from here + solstice_reward_bootstrap: SolsticeRewardBootstrapParams { + swa_timelock_epochs: 7 * EPOCHS_IN_DAY, + consensus_weight_ramp_duration_epochs: 9 * 90 * EPOCHS_IN_DAY, + consensus_weight: SOLSTICE_CONSENSUS_WEIGHT, + service_weight: SOLSTICE_SERVICE_WEIGHT, + // Reserved but not deployed yet; the migration needs their `f0` addresses. + swa_actor: None, + sra_actor: None, + initial_orchestrator: None, + }, f3_enabled: true, f3_consensus: true, // April 29 at 10:00 UTC @@ -346,6 +409,16 @@ impl ChainConfig { fip0081_ramp_duration_epochs: 3 * EPOCHS_IN_DAY as u64, // FIP-0100: 300M -> 1.2B FIL upgrade_teep_initial_fil_reserved: Some(TokenAmount::from_whole(1_200_000_000)), + // Taken from here: + solstice_reward_bootstrap: SolsticeRewardBootstrapParams { + swa_timelock_epochs: EPOCHS_IN_HOUR, + consensus_weight_ramp_duration_epochs: 7 * EPOCHS_IN_DAY, + consensus_weight: SOLSTICE_CONSENSUS_WEIGHT, + service_weight: SOLSTICE_SERVICE_WEIGHT, + swa_actor: None, + sra_actor: None, + initial_orchestrator: None, + }, // Enable after `f3_initial_power_table` is determined and set to avoid GC hell // (state tree of epoch 3_451_774 - 900 has to be present in the database if `f3_initial_power_table` is not set) f3_enabled: true, @@ -383,6 +456,18 @@ impl ChainConfig { fip0081_ramp_duration_epochs: env_or_default(ENV_PLEDGE_RULE_RAMP, 200), // FIP-0100: 300M -> 1.4B FIL upgrade_teep_initial_fil_reserved: Some(TokenAmount::from_whole(1_400_000_000)), + // Same as the Lotus 2k network: . + // The reward actor rejects the burnt-funds orchestrator as stored state while + // go-state-types accepts it; see the reward migration tests. + solstice_reward_bootstrap: SolsticeRewardBootstrapParams { + swa_timelock_epochs: 50, + consensus_weight_ramp_duration_epochs: 900, + consensus_weight: SOLSTICE_CONSENSUS_WEIGHT, + service_weight: SOLSTICE_SERVICE_WEIGHT, + swa_actor: Some(Address::SYSTEM_ACTOR), + sra_actor: Some(Address::SYSTEM_ACTOR), + initial_orchestrator: Some(Address::BURNT_FUNDS_ACTOR), + }, f3_enabled: false, f3_consensus: false, f3_bootstrap_epoch: -1, @@ -419,6 +504,16 @@ impl ChainConfig { ), // FIP-0100: 300M -> 1.6B FIL upgrade_teep_initial_fil_reserved: Some(TokenAmount::from_whole(1_600_000_000)), + // Take from here + solstice_reward_bootstrap: SolsticeRewardBootstrapParams { + swa_timelock_epochs: 7 * EPOCHS_IN_DAY, + consensus_weight_ramp_duration_epochs: 9 * 90 * EPOCHS_IN_DAY, + consensus_weight: SOLSTICE_CONSENSUS_WEIGHT, + service_weight: SOLSTICE_SERVICE_WEIGHT, + swa_actor: None, + sra_actor: None, + initial_orchestrator: None, + }, f3_enabled: true, f3_consensus: true, f3_bootstrap_epoch: 1000, diff --git a/src/shim/clock.rs b/src/shim/clock.rs index 3740cdd5e89e..2479321170b0 100644 --- a/src/shim/clock.rs +++ b/src/shim/clock.rs @@ -2,9 +2,8 @@ // SPDX-License-Identifier: Apache-2.0, MIT pub use super::fvm_shared_latest::clock::ChainEpoch; +pub use fil_actors_shared::v19::EPOCHS_IN_DAY; +pub use fil_actors_shared::v19::EPOCHS_IN_HOUR; pub use fvm_shared3::ALLOWABLE_CLOCK_DRIFT; pub use fvm_shared3::BLOCKS_PER_EPOCH; pub use fvm_shared3::clock::EPOCH_DURATION_SECONDS; - -pub const SECONDS_IN_DAY: i64 = 86400; -pub const EPOCHS_IN_DAY: i64 = SECONDS_IN_DAY / EPOCH_DURATION_SECONDS; From 1c39046976faed769e9b88c6ae1fd77d980e0f1d Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Fri, 4 Sep 2026 19:58:31 +0530 Subject: [PATCH 3/9] feat: add the NV29 state migration --- src/state_migration/common/mod.rs | 14 + src/state_migration/mod.rs | 1 + src/state_migration/nv29/market.rs | 94 ++++ src/state_migration/nv29/migration.rs | 108 +++++ src/state_migration/nv29/mod.rs | 22 + src/state_migration/nv29/reward.rs | 599 ++++++++++++++++++++++++++ 6 files changed, 838 insertions(+) create mode 100644 src/state_migration/nv29/market.rs create mode 100644 src/state_migration/nv29/migration.rs create mode 100644 src/state_migration/nv29/mod.rs create mode 100644 src/state_migration/nv29/reward.rs diff --git a/src/state_migration/common/mod.rs b/src/state_migration/common/mod.rs index f1848e46bf45..7f6395a3194c 100644 --- a/src/state_migration/common/mod.rs +++ b/src/state_migration/common/mod.rs @@ -63,6 +63,20 @@ pub(in crate::state_migration) struct ActorMigrationInput { pub cache: MigrationCache, } +#[cfg(test)] +impl ActorMigrationInput { + /// Input for migrating one actor head in unit tests; the other fields are placeholders. + pub(in crate::state_migration) fn for_head(head: Cid) -> Self { + Self { + address: Address::new_id(0), + balance: TokenAmount::default(), + head, + prior_epoch: 0, + cache: MigrationCache::new(nonzero_ext::nonzero!(1usize)), + } + } +} + /// Output of actor migration job. pub(in crate::state_migration) struct ActorMigrationOutput { /// New CID for the actor diff --git a/src/state_migration/mod.rs b/src/state_migration/mod.rs index d9aaadd420c4..08bd9545d0b1 100644 --- a/src/state_migration/mod.rs +++ b/src/state_migration/mod.rs @@ -25,6 +25,7 @@ mod nv25; mod nv26fix; mod nv27; mod nv28; +mod nv29; mod type_migrations; type RunMigration = fn(&ChainConfig, &DB, &Cid, ChainEpoch) -> anyhow::Result; diff --git a/src/state_migration/nv29/market.rs b/src/state_migration/nv29/market.rs new file mode 100644 index 000000000000..be6b01ff4063 --- /dev/null +++ b/src/state_migration/nv29/market.rs @@ -0,0 +1,94 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! Market actor migration for FIP-0118: re-encodes the state without +//! `pending_deal_allocation_ids`. + +use crate::state_migration::common::{ + ActorMigration, ActorMigrationInput, ActorMigrationOutput, TypeMigration, TypeMigrator, +}; +use crate::utils::db::CborStoreExt as _; +use cid::Cid; +use fil_actor_market_state::v18::State as MarketStateOld; +use fil_actor_market_state::v19::State as MarketStateNew; +use fvm_ipld_blockstore::Blockstore; + +pub struct MarketMigrator { + pub new_code_cid: Cid, +} + +impl ActorMigration for MarketMigrator { + fn migrate_state( + &self, + store: &BS, + input: ActorMigrationInput, + ) -> anyhow::Result> { + let in_state: MarketStateOld = store.get_cbor_required(&input.head)?; + let out_state: MarketStateNew = TypeMigrator::migrate_type(in_state, store)?; + let new_head = store.put_cbor_default(&out_state)?; + Ok(Some(ActorMigrationOutput { + new_code_cid: self.new_code_cid, + new_head, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::MemoryDB; + use crate::utils::cid::CidCborExt as _; + use fvm_ipld_encoding::CborStore as _; + use fvm_shared4::econ::TokenAmount; + + // The dropped field sits mid-tuple, so a shifted field would show up as a wrong value + // rather than a missing one. + #[test] + fn drops_pending_deal_allocation_ids_and_keeps_every_other_field() { + let store = MemoryDB::default(); + let distinct_cid = |tag: u64| store.put_cbor_default(&tag).unwrap(); + let in_state = MarketStateOld { + proposals: distinct_cid(1), + states: distinct_cid(2), + pending_proposals: distinct_cid(3), + escrow_table: distinct_cid(4), + locked_table: distinct_cid(5), + next_id: 1234, + deal_ops_by_epoch: distinct_cid(6), + last_cron: 5678, + total_client_locked_collateral: TokenAmount::from_atto(11), + total_provider_locked_collateral: TokenAmount::from_atto(22), + total_client_storage_fee: TokenAmount::from_atto(33), + pending_deal_allocation_ids: distinct_cid(7), + provider_sectors: distinct_cid(8), + }; + let head = store.put_cbor_default(&in_state).unwrap(); + let new_code_cid = Cid::from_cbor_blake2b256(&"market v19 code").unwrap(); + + let output = MarketMigrator { new_code_cid } + .migrate_state(&store, ActorMigrationInput::for_head(head)) + .unwrap() + .unwrap(); + + assert_eq!(output.new_code_cid, new_code_cid); + let out_state: MarketStateNew = store.get_cbor_required(&output.new_head).unwrap(); + let expected = MarketStateNew { + proposals: in_state.proposals, + states: in_state.states, + pending_proposals: in_state.pending_proposals, + escrow_table: in_state.escrow_table, + locked_table: in_state.locked_table, + next_id: in_state.next_id, + deal_ops_by_epoch: in_state.deal_ops_by_epoch, + last_cron: in_state.last_cron, + total_client_locked_collateral: in_state.total_client_locked_collateral.clone(), + total_provider_locked_collateral: in_state.total_provider_locked_collateral.clone(), + total_client_storage_fee: in_state.total_client_storage_fee.clone(), + provider_sectors: in_state.provider_sectors, + }; + // `State` has no `PartialEq`. + assert_eq!(format!("{out_state:?}"), format!("{expected:?}")); + // The v19 tuple is one field shorter, so it no longer decodes as v18. + assert!(store.get_cbor::(&output.new_head).is_err()); + } +} diff --git a/src/state_migration/nv29/migration.rs b/src/state_migration/nv29/migration.rs new file mode 100644 index 000000000000..4b354a256c67 --- /dev/null +++ b/src/state_migration/nv29/migration.rs @@ -0,0 +1,108 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT +// +//! This module contains the migration logic for the `NV29` upgrade. + +use super::market::MarketMigrator; +use super::reward::RewardMigrator; +use super::{SystemStateOld, system, verifier::Verifier}; +use crate::networks::{ChainConfig, Height, SolsticeRewardBootstrapParams}; +use crate::prelude::*; +use crate::shim::{ + address::Address, + clock::ChainEpoch, + machine::{BuiltinActor, BuiltinActorManifest}, + state_tree::{StateTree, StateTreeVersion}, +}; +use crate::state_migration::common::{StateMigration, migrators::nil_migrator}; +use crate::utils::db::CborStoreExt as _; + +impl StateMigration { + pub fn add_nv29_migrations( + &mut self, + store: &BS, + state: &Cid, + new_manifest: &BuiltinActorManifest, + reward_bootstrap: &SolsticeRewardBootstrapParams, + activation_epoch: ChainEpoch, + ) -> anyhow::Result<()> { + let state_tree = StateTree::new_from_root(store, state)?; + let system_actor = state_tree.get_required_actor(&Address::SYSTEM_ACTOR)?; + let system_actor_state = store.get_cbor_required::(&system_actor.state)?; + + let current_manifest_data = system_actor_state.builtin_actors; + + let current_manifest = + BuiltinActorManifest::load_v1_actor_list(store, ¤t_manifest_data)?; + + for (name, code) in current_manifest.builtin_actors() { + let new_code = new_manifest.get(name)?; + self.add_migrator(code, nil_migrator(new_code)) + } + + self.add_migrator( + current_manifest.get_system(), + system::system_migrator(new_manifest), + ); + self.add_migrator( + current_manifest.get(BuiltinActor::Reward)?, + Arc::new(RewardMigrator::new( + reward_bootstrap, + activation_epoch, + new_manifest.get(BuiltinActor::Reward)?, + )?), + ); + self.add_migrator( + current_manifest.get(BuiltinActor::Market)?, + Arc::new(MarketMigrator { + new_code_cid: new_manifest.get(BuiltinActor::Market)?, + }), + ); + + Ok(()) + } +} + +/// Runs the migration for `NV29`. Returns the new state root. +pub fn run_migration( + chain_config: &ChainConfig, + blockstore: &DB, + state: &Cid, + epoch: ChainEpoch, +) -> anyhow::Result +where + DB: Blockstore + ShallowClone + Send + Sync, +{ + let new_manifest_cid = chain_config + .height_infos + .get(&Height::Solstice) + .context("no height info for network version NV29")? + .bundle + .as_ref() + .context("no bundle for network version NV29")?; + + blockstore.get(new_manifest_cid)?.context(format!( + "manifest for network version NV29 not found in blockstore: {new_manifest_cid}" + ))?; + + // Add migration specification verification + let verifier = Arc::new(Verifier::default()); + + let new_manifest = BuiltinActorManifest::load_manifest(blockstore, new_manifest_cid)?; + let mut migration = StateMigration::::new(Some(verifier)); + // The bootstrap streams start at the first epoch executed on the migrated state, like + // go-state-types' `activationEpoch := priorEpoch + 1`. + migration.add_nv29_migrations( + blockstore, + state, + &new_manifest, + &chain_config.solstice_reward_bootstrap, + epoch + 1, + )?; + + let actors_in = StateTree::new_from_root(blockstore, state)?; + let actors_out = StateTree::new(blockstore, StateTreeVersion::V5)?; + let new_state = migration.migrate_state_tree(blockstore, epoch, actors_in, actors_out)?; + + Ok(new_state) +} diff --git a/src/state_migration/nv29/mod.rs b/src/state_migration/nv29/mod.rs new file mode 100644 index 000000000000..85499de7ada1 --- /dev/null +++ b/src/state_migration/nv29/mod.rs @@ -0,0 +1,22 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! This module contains the migration logic for the `NV29` upgrade. +mod market; +mod migration; +mod reward; + +/// Run migration for `NV29`. This should be the only exported method in this +/// module. +#[allow(unused)] +pub use migration::run_migration; + +use crate::{define_system_states, impl_system, impl_verifier}; + +define_system_states!( + fil_actor_system_state::v18::State, + fil_actor_system_state::v19::State +); + +impl_system!(); +impl_verifier!(); diff --git a/src/state_migration/nv29/reward.rs b/src/state_migration/nv29/reward.rs new file mode 100644 index 000000000000..dca8e694a59f --- /dev/null +++ b/src/state_migration/nv29/reward.rs @@ -0,0 +1,599 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! Reward actor migration for FIP-0118: keeps the reward accounting, drops the stored reward +//! totals and installs the bootstrap streams and the stream weight authority (SWA). +//! +//! Ports the go-state-types migrator and the Lotus stream derivation; the built streams are +//! vetted by the actor crate's own `validate_streams_state`, the check the actor repeats on +//! every block reward: +//! +//! + +use crate::networks::{SolsticeRewardBootstrapParams, SolsticeRewardWeightParams}; +use crate::shim::address::{Address, Protocol}; +use crate::state_migration::common::{ActorMigration, ActorMigrationInput, ActorMigrationOutput}; +use crate::utils::db::CborStoreExt as _; +use anyhow::{Context as _, ensure}; +use cid::Cid; +use fil_actor_reward_state::v18::State as RewardStateOld; +use fil_actor_reward_state::v19::{ + DENOM, ExplicitDistribution, RecipientShare, State as RewardStateNew, Stream, StreamAccrual, + StreamId, StreamsState, WeightRecord, validate_streams_state, +}; +use fil_actors_shared::v19::builtin::reward::smooth::FilterEstimate; +use fvm_ipld_blockstore::Blockstore; +use fvm_shared4::address::Address as Address_v4; +use fvm_shared4::clock::ChainEpoch; +use fvm_shared4::econ::TokenAmount; +use num_traits::Zero as _; + +const CONSENSUS_STREAM_ID: StreamId = 1; +const SERVICE_STREAM_ID: StreamId = 2; + +/// Consensus-only bootstrap: the whole block reward keeps flowing to block producers. +const NEUTRAL_CONSENSUS_WEIGHT: SolsticeRewardWeightParams = SolsticeRewardWeightParams { + v_start: DENOM, + floor: DENOM, + cap: DENOM, +}; +const NO_SERVICE_WEIGHT: SolsticeRewardWeightParams = SolsticeRewardWeightParams { + v_start: 0, + floor: 0, + cap: 0, +}; + +pub struct RewardMigrator { + new_code_cid: Cid, + streams: StreamsState, + accrued: Vec, + swa_timelock_epochs: ChainEpoch, + swa_actor: Address_v4, +} + +impl RewardMigrator { + /// Derives the bootstrap streams starting at `activation_epoch`, the first epoch executed on + /// the migrated state, and vets them with the actor's own state validation. + /// + /// # Errors + /// Fails on the inputs Lotus and go-state-types reject: a missing or non-ID bootstrap + /// address, a negative SWA timelock, a negative ramp, a zero ramp with anything but the + /// neutral weights, or weights that do not start at `DENOM` together, leave their bounds or + /// exceed `DENOM` later. + pub fn new( + params: &SolsticeRewardBootstrapParams, + activation_epoch: ChainEpoch, + new_code_cid: Cid, + ) -> anyhow::Result { + let (streams, accrued) = bootstrap_streams(params, activation_epoch)?; + validate_streams_state(&streams, &accrued, activation_epoch)?; + ensure!(params.swa_timelock_epochs >= 0, "SWA timelock is negative"); + let swa_actor = required_address(params.swa_actor, "SWA actor")?; + ensure!( + swa_actor.protocol() == Protocol::ID, + "SWA actor is not an ID address" + ); + + Ok(Self { + new_code_cid, + streams, + accrued, + swa_timelock_epochs: params.swa_timelock_epochs, + swa_actor, + }) + } +} + +/// Bootstrap streams and their accruals as Lotus derives them: the consensus stream alone at +/// constant `DENOM` for a zero ramp, otherwise consensus and service streams trading weight at +/// the same rate until the consensus stream reaches its floor, with one zero accrual for the +/// service stream. +fn bootstrap_streams( + params: &SolsticeRewardBootstrapParams, + activation_epoch: ChainEpoch, +) -> anyhow::Result<(StreamsState, Vec)> { + let record = |weight: SolsticeRewardWeightParams, slope: i64| WeightRecord { + v_start: weight.v_start, + slope, + t_start: activation_epoch, + floor: weight.floor, + cap: weight.cap, + }; + + let streams = if params.consensus_weight_ramp_duration_epochs == 0 { + ensure!( + params.consensus_weight == NEUTRAL_CONSENSUS_WEIGHT + && params.service_weight == NO_SERVICE_WEIGHT, + "zero-duration Solstice bootstrap must have constant DENOM consensus weight and zero service weight" + ); + vec![Stream { + id: CONSENSUS_STREAM_ID, + weight: record(params.consensus_weight, 0), + distribution: None, + }] + } else { + let slope = consensus_weight_slope( + params.consensus_weight, + params.consensus_weight_ramp_duration_epochs, + )?; + // The actor accepts weights that start below `DENOM` (the rest burns); go-state-types + // does not, so Lotus would refuse such a bootstrap. + ensure!( + params.consensus_weight.v_start <= DENOM + && params.service_weight.v_start == DENOM - params.consensus_weight.v_start, + "bootstrap starting weights must sum to denominator" + ); + let sra_actor = required_address(params.sra_actor, "SRA actor")?; + let initial_orchestrator = + required_address(params.initial_orchestrator, "initial orchestrator")?; + vec![ + Stream { + id: CONSENSUS_STREAM_ID, + weight: record(params.consensus_weight, -slope), + distribution: None, + }, + Stream { + id: SERVICE_STREAM_ID, + weight: record(params.service_weight, slope), + distribution: Some(ExplicitDistribution { + writer: sra_actor, + shares: vec![RecipientShare { + recipient: initial_orchestrator, + share: DENOM, + }], + payable: Vec::new(), + claimed_period: Vec::new(), + }), + }, + ] + }; + let accrued = streams + .iter() + .filter(|stream| stream.distribution.is_some()) + .map(|stream| StreamAccrual { + id: stream.id, + amount: TokenAmount::zero(), + }) + .collect(); + let streams = StreamsState { + streams, + tombstones: Vec::new(), + pending_writes: Vec::new(), + }; + Ok((streams, accrued)) +} + +/// Weight moved from the consensus stream to the service stream each epoch, rounded up so the +/// consensus weight reaches its floor within the ramp even when the total is not divisible. +fn consensus_weight_slope( + weight: SolsticeRewardWeightParams, + ramp_epochs: ChainEpoch, +) -> anyhow::Result { + ensure!( + ramp_epochs > 0, + "Solstice consensus weight ramp duration is negative: {ramp_epochs}" + ); + ensure!( + weight.v_start > weight.floor, + "Solstice consensus weight start {} must exceed its floor {}", + weight.v_start, + weight.floor + ); + let slope = (weight.v_start - weight.floor).div_ceil(ramp_epochs.unsigned_abs()); + i64::try_from(slope) + .with_context(|| format!("Solstice consensus weight ramp produces invalid slope {slope}")) +} + +/// Lotus passes an unset address through as `address.Undef` and lets the ID check reject it; +/// Forest models unset as `None` and names the missing input. +fn required_address(address: Option
, name: &str) -> anyhow::Result { + let address = address.with_context(|| { + format!("{name} is not set: the Solstice migration needs its f0 address") + })?; + Ok(Address_v4::from(&address)) +} + +impl ActorMigration for RewardMigrator { + fn migrate_state( + &self, + store: &BS, + input: ActorMigrationInput, + ) -> anyhow::Result> { + let in_state: RewardStateOld = store.get_cbor_required(&input.head)?; + let streams_root = store.put_cbor_default(&self.streams)?; + // `simple_total` and `baseline_total` are dropped: v19 derives them from constants. + let out_state = RewardStateNew { + cumsum_baseline: in_state.cumsum_baseline, + cumsum_realized: in_state.cumsum_realized, + effective_network_time: in_state.effective_network_time, + effective_baseline_power: in_state.effective_baseline_power, + this_epoch_reward: in_state.this_epoch_reward, + this_epoch_reward_smoothed: FilterEstimate { + position: in_state.this_epoch_reward_smoothed.position, + velocity: in_state.this_epoch_reward_smoothed.velocity, + }, + this_epoch_baseline_power: in_state.this_epoch_baseline_power, + epoch: in_state.epoch, + total_minted_reward: in_state.total_storage_power_reward, + total_burn_minted: TokenAmount::zero(), + total_explicit_minted: TokenAmount::zero(), + accrued: self.accrued.clone(), + swa_timelock_epochs: self.swa_timelock_epochs, + swa_actor: self.swa_actor, + streams_root, + }; + let new_head = store.put_cbor_default(&out_state)?; + Ok(Some(ActorMigrationOutput { + new_code_cid: self.new_code_cid, + new_head, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::MemoryDB; + use crate::networks::{ChainConfig, Height, UPGRADE_HEIGHT_UNSCHEDULED}; + use crate::utils::cid::CidCborExt as _; + use fil_actors_shared::v18::builtin::reward::smooth::FilterEstimate as FilterEstimateOld; + + const PERCENT: u64 = DENOM / 100; + + fn weight(v_start: u64, floor: u64, cap: u64) -> SolsticeRewardWeightParams { + SolsticeRewardWeightParams { + v_start: v_start * PERCENT, + floor: floor * PERCENT, + cap: cap * PERCENT, + } + } + + fn bootstrap_params() -> SolsticeRewardBootstrapParams { + SolsticeRewardBootstrapParams { + swa_timelock_epochs: 20_160, + consensus_weight_ramp_duration_epochs: 81, + consensus_weight: weight(95, 50, 95), + service_weight: weight(5, 5, 10), + swa_actor: Some(Address::new_id(100)), + sra_actor: Some(Address::new_id(101)), + initial_orchestrator: Some(Address::new_id(102)), + } + } + + #[test] + fn migrates_v18_state_and_installs_bootstrap_streams() { + let store = MemoryDB::default(); + // Distinct values in every field, so a shifted field would show up as a wrong value. + let in_state = RewardStateOld { + cumsum_baseline: 1.into(), + cumsum_realized: 2.into(), + effective_network_time: 3, + effective_baseline_power: 4.into(), + this_epoch_reward: TokenAmount::from_atto(5), + this_epoch_reward_smoothed: FilterEstimateOld { + position: 6.into(), + velocity: 7.into(), + }, + this_epoch_baseline_power: 8.into(), + epoch: 9, + total_storage_power_reward: TokenAmount::from_atto(10), + simple_total: TokenAmount::from_atto(11), + baseline_total: TokenAmount::from_atto(12), + }; + let head = store.put_cbor_default(&in_state).unwrap(); + let new_code_cid = Cid::from_cbor_blake2b256(&"reward v19 code").unwrap(); + let activation_epoch = 100; + + let output = RewardMigrator::new(&bootstrap_params(), activation_epoch, new_code_cid) + .unwrap() + .migrate_state(&store, ActorMigrationInput::for_head(head)) + .unwrap() + .unwrap(); + assert_eq!(output.new_code_cid, new_code_cid); + + // 45% of DENOM moves from consensus to service over the 81-epoch ramp, rounded up. + let slope = 5_555_555_555_555_556; + let expected_streams = StreamsState { + streams: vec![ + Stream { + id: 1, + weight: WeightRecord { + v_start: 95 * PERCENT, + slope: -slope, + t_start: activation_epoch, + floor: 50 * PERCENT, + cap: 95 * PERCENT, + }, + distribution: None, + }, + Stream { + id: 2, + weight: WeightRecord { + v_start: 5 * PERCENT, + slope, + t_start: activation_epoch, + floor: 5 * PERCENT, + cap: 10 * PERCENT, + }, + distribution: Some(ExplicitDistribution { + writer: Address_v4::new_id(101), + shares: vec![RecipientShare { + recipient: Address_v4::new_id(102), + share: DENOM, + }], + payable: vec![], + claimed_period: vec![], + }), + }, + ], + tombstones: vec![], + pending_writes: vec![], + }; + let out_state: RewardStateNew = store.get_cbor_required(&output.new_head).unwrap(); + assert_eq!( + store + .get_cbor_required::(&out_state.streams_root) + .unwrap(), + expected_streams + ); + + let expected = RewardStateNew { + cumsum_baseline: 1.into(), + cumsum_realized: 2.into(), + effective_network_time: 3, + effective_baseline_power: 4.into(), + this_epoch_reward: TokenAmount::from_atto(5), + this_epoch_reward_smoothed: FilterEstimate { + position: 6.into(), + velocity: 7.into(), + }, + this_epoch_baseline_power: 8.into(), + epoch: 9, + total_minted_reward: TokenAmount::from_atto(10), + total_burn_minted: TokenAmount::zero(), + total_explicit_minted: TokenAmount::zero(), + accrued: vec![StreamAccrual { + id: 2, + amount: TokenAmount::zero(), + }], + swa_timelock_epochs: 20_160, + swa_actor: Address_v4::new_id(100), + streams_root: store.put_cbor_default(&expected_streams).unwrap(), + }; + // `State` has no `PartialEq`. + assert_eq!(format!("{out_state:?}"), format!("{expected:?}")); + } + + #[test] + fn zero_ramp_installs_the_consensus_stream_alone() { + let params = SolsticeRewardBootstrapParams { + consensus_weight_ramp_duration_epochs: 0, + consensus_weight: weight(100, 100, 100), + service_weight: weight(0, 0, 0), + sra_actor: None, + initial_orchestrator: None, + ..bootstrap_params() + }; + + let migrator = RewardMigrator::new(¶ms, 100, Cid::default()).unwrap(); + + assert_eq!( + migrator.streams, + StreamsState { + streams: vec![Stream { + id: 1, + weight: WeightRecord { + v_start: DENOM, + slope: 0, + t_start: 100, + floor: DENOM, + cap: DENOM, + }, + distribution: None, + }], + tombstones: vec![], + pending_writes: vec![], + } + ); + assert!(migrator.accrued.is_empty()); + } + + #[test] + fn consensus_weight_slope_rounds_up_to_reach_the_floor_within_the_ramp() { + // (ramp epochs, per-epoch slope): 45% of DENOM spread over the ramp. + for (ramp_epochs, expected_slope) in [ + (900, 500_000_000_000_000), + (81, 5_555_555_555_555_556), + (20_160, 22_321_428_571_429), + (2_332_800, 192_901_234_568), + ] { + assert_eq!( + consensus_weight_slope(weight(95, 50, 95), ramp_epochs).unwrap(), + expected_slope + ); + } + } + + #[test] + fn rejects_incomplete_or_invalid_bootstrap_params() { + let valid = bootstrap_params(); + let delegated = Some(Address::new_delegated(10, &[1]).unwrap()); + for (case, params, expected_error) in [ + ( + "unset SWA", + SolsticeRewardBootstrapParams { + swa_actor: None, + ..valid.clone() + }, + "SWA actor is not set", + ), + ( + "unset SRA", + SolsticeRewardBootstrapParams { + sra_actor: None, + ..valid.clone() + }, + "SRA actor is not set", + ), + ( + "unset orchestrator", + SolsticeRewardBootstrapParams { + initial_orchestrator: None, + ..valid.clone() + }, + "initial orchestrator is not set", + ), + ( + "non-ID SWA", + SolsticeRewardBootstrapParams { + swa_actor: delegated, + ..valid.clone() + }, + "SWA actor is not an ID address", + ), + ( + "non-ID SRA", + SolsticeRewardBootstrapParams { + sra_actor: delegated, + ..valid.clone() + }, + "distribution writer f410", + ), + ( + "non-ID orchestrator", + SolsticeRewardBootstrapParams { + initial_orchestrator: delegated, + ..valid.clone() + }, + "share recipient f410", + ), + ( + "negative timelock", + SolsticeRewardBootstrapParams { + swa_timelock_epochs: -1, + ..valid.clone() + }, + "SWA timelock is negative", + ), + ( + "negative ramp", + SolsticeRewardBootstrapParams { + consensus_weight_ramp_duration_epochs: -1, + ..valid.clone() + }, + "ramp duration is negative", + ), + ( + "zero ramp with split weights", + SolsticeRewardBootstrapParams { + consensus_weight_ramp_duration_epochs: 0, + ..valid.clone() + }, + "zero-duration Solstice bootstrap must have constant DENOM consensus weight and zero service weight", + ), + ( + "consensus start not above its floor", + SolsticeRewardBootstrapParams { + consensus_weight: weight(50, 50, 95), + ..valid.clone() + }, + "must exceed its floor", + ), + ( + "starting weights do not sum to DENOM", + SolsticeRewardBootstrapParams { + service_weight: weight(6, 5, 10), + ..valid.clone() + }, + "starting weights must sum to denominator", + ), + ( + "service cap above what the consensus floor leaves", + SolsticeRewardBootstrapParams { + service_weight: weight(5, 5, 60), + ..valid.clone() + }, + "stream weights exceed DENOM", + ), + ( + "weight start above its cap", + SolsticeRewardBootstrapParams { + consensus_weight: weight(95, 50, 94), + ..valid + }, + "weight v_start exceeds cap", + ), + ] { + let error = RewardMigrator::new(¶ms, 100, Cid::default()) + .err() + .unwrap_or_else(|| panic!("{case}: accepted")); + assert!( + format!("{error:#}").contains(expected_error), + "{case}: {error:#}" + ); + } + } + + // The bootstrap names contracts that must exist before the upgrade runs, so a scheduled + // height and complete addresses go together. + #[test] + fn scheduled_networks_have_complete_bootstrap_addresses() { + for config in [ + ChainConfig::mainnet(), + ChainConfig::calibnet(), + ChainConfig::butterflynet(), + ] { + let solstice_epoch = config.epoch(Height::Solstice); + let scheduled = solstice_epoch != UPGRADE_HEIGHT_UNSCHEDULED; + let bootstrap = RewardMigrator::new( + &config.solstice_reward_bootstrap, + solstice_epoch + 1, + Cid::default(), + ); + assert_eq!( + bootstrap.is_ok(), + scheduled, + "{}: schedule Solstice only once SWA, SRA and orchestrator have f0 addresses", + config.network + ); + } + } + + // Lotus 2k names the burnt-funds actor as orchestrator. The reward actor rejects that as + // stored state and pays no block reward on it, while go-state-types accepts it; Forest + // follows the actor. Re-sync the devnet params once upstream agrees. + #[test] + fn devnet_bootstrap_is_rejected_until_upstream_agrees_on_the_orchestrator() { + let error = RewardMigrator::new( + &ChainConfig::devnet().solstice_reward_bootstrap, + 1, + Cid::default(), + ) + .err() + .expect("burnt-funds orchestrator accepted"); + assert!( + format!("{error:#}").contains("burn sentinel persisted as a recipient"), + "{error:#}" + ); + } + + // Only the addresses are missing on the public networks; their timelocks, ramps and weights + // already pass the checks. + #[test] + fn public_network_params_are_valid_once_addresses_are_set() { + for config in [ + ChainConfig::mainnet(), + ChainConfig::calibnet(), + ChainConfig::butterflynet(), + ] { + let params = SolsticeRewardBootstrapParams { + swa_actor: Some(Address::new_id(100)), + sra_actor: Some(Address::new_id(101)), + initial_orchestrator: Some(Address::new_id(102)), + ..config.solstice_reward_bootstrap + }; + RewardMigrator::new(¶ms, 1, Cid::default()) + .unwrap_or_else(|e| panic!("{}: {e:#}", config.network)); + } + } +} From 7dbcbb5cd548362354d61e1e4cd8d092f47edc02 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Thu, 10 Sep 2026 14:36:10 +0530 Subject: [PATCH 4/9] add validation for the reward actor params for the nv29 migration --- src/networks/mod.rs | 97 +---- src/state_migration/nv29/market.rs | 1 + src/state_migration/nv29/migration.rs | 20 +- src/state_migration/nv29/mod.rs | 1 + src/state_migration/nv29/reward.rs | 361 +++++++++++++------ src/state_migration/nv29/reward_bootstrap.rs | 86 +++++ 6 files changed, 354 insertions(+), 212 deletions(-) create mode 100644 src/state_migration/nv29/reward_bootstrap.rs diff --git a/src/networks/mod.rs b/src/networks/mod.rs index 3c940abde240..1f145317a05c 100644 --- a/src/networks/mod.rs +++ b/src/networks/mod.rs @@ -16,8 +16,7 @@ use crate::db::SettingsStore; use crate::eth::EthChainId; use crate::prelude::*; use crate::shim::{ - address::Address, - clock::{ChainEpoch, EPOCH_DURATION_SECONDS, EPOCHS_IN_DAY, EPOCHS_IN_HOUR}, + clock::{ChainEpoch, EPOCH_DURATION_SECONDS, EPOCHS_IN_DAY}, econ::TokenAmount, machine::BuiltinActorManifest, runtime::Policy, @@ -252,56 +251,6 @@ struct DrandPoint<'a> { pub config: &'a LazyLock>, } -/// A clamped linear stream weight in `DENOM` fixed point, without its slope and start epoch. -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Copy)] -#[cfg_attr(test, derive(derive_quickcheck_arbitrary::Arbitrary))] -pub struct SolsticeRewardWeightParams { - pub v_start: u64, - pub floor: u64, - pub cap: u64, -} - -/// Lotus `solsticeRewardWeightPercent`. -const SOLSTICE_REWARD_WEIGHT_PERCENT: u64 = fil_actor_reward_state::v19::DENOM / 100; - -/// FIP-0118 bootstrap weights, the same on every network: the consensus stream starts at 95% of -/// the block reward and ramps down to 50%; the service stream starts at 5% and ramps up to 10%. -const SOLSTICE_CONSENSUS_WEIGHT: SolsticeRewardWeightParams = SolsticeRewardWeightParams { - v_start: 95 * SOLSTICE_REWARD_WEIGHT_PERCENT, - floor: 50 * SOLSTICE_REWARD_WEIGHT_PERCENT, - cap: 95 * SOLSTICE_REWARD_WEIGHT_PERCENT, -}; -const SOLSTICE_SERVICE_WEIGHT: SolsticeRewardWeightParams = SolsticeRewardWeightParams { - v_start: 5 * SOLSTICE_REWARD_WEIGHT_PERCENT, - floor: 5 * SOLSTICE_REWARD_WEIGHT_PERCENT, - cap: 10 * SOLSTICE_REWARD_WEIGHT_PERCENT, -}; - -/// FIP-0118 reward actor bootstrap installed by the Solstice (NV29) state migration. -/// -/// Mirrors Lotus `SolsticeRewardBootstrapParams` field for field, with the per-network values -/// taken from the `params_.go` files at the same commit: -/// -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] -#[cfg_attr(test, derive(derive_quickcheck_arbitrary::Arbitrary))] -pub struct SolsticeRewardBootstrapParams { - /// Delay before a stream weight authority (SWA) write takes effect. - pub swa_timelock_epochs: ChainEpoch, - /// Epochs over which the consensus stream weight ramps down from its start to its floor. - /// Zero installs the consensus stream alone at constant `DENOM` (no service stream). - pub consensus_weight_ramp_duration_epochs: ChainEpoch, - /// Weight of the stream paid to block producers. - pub consensus_weight: SolsticeRewardWeightParams, - /// Weight of the stream paid to the orchestrator. - pub service_weight: SolsticeRewardWeightParams, - /// Stream weight authority contract. `None` until it is deployed and has an `f0` address. - pub swa_actor: Option
, - /// Service reward authority contract, the writer of the service stream's shares. - pub sra_actor: Option
, - /// Sole initial recipient of the service stream. - pub initial_orchestrator: Option
, -} - /// Defines all network configuration parameters. #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] #[cfg_attr(test, derive(derive_quickcheck_arbitrary::Arbitrary))] @@ -330,7 +279,6 @@ pub struct ChainConfig { pub fip0081_ramp_duration_epochs: u64, // See FIP-0100 and https://github.com/filecoin-project/lotus/pull/12938 for why this exists pub upgrade_teep_initial_fil_reserved: Option, - pub solstice_reward_bootstrap: SolsticeRewardBootstrapParams, pub f3_enabled: bool, // F3Consensus set whether F3 should checkpoint tipsets finalized by F3. This flag has no effect if F3 is not enabled. pub f3_consensus: bool, @@ -360,17 +308,6 @@ impl ChainConfig { // 1 year on mainnet fip0081_ramp_duration_epochs: 365 * EPOCHS_IN_DAY as u64, upgrade_teep_initial_fil_reserved: None, - // Taken from here - solstice_reward_bootstrap: SolsticeRewardBootstrapParams { - swa_timelock_epochs: 7 * EPOCHS_IN_DAY, - consensus_weight_ramp_duration_epochs: 9 * 90 * EPOCHS_IN_DAY, - consensus_weight: SOLSTICE_CONSENSUS_WEIGHT, - service_weight: SOLSTICE_SERVICE_WEIGHT, - // Reserved but not deployed yet; the migration needs their `f0` addresses. - swa_actor: None, - sra_actor: None, - initial_orchestrator: None, - }, f3_enabled: true, f3_consensus: true, // April 29 at 10:00 UTC @@ -409,16 +346,6 @@ impl ChainConfig { fip0081_ramp_duration_epochs: 3 * EPOCHS_IN_DAY as u64, // FIP-0100: 300M -> 1.2B FIL upgrade_teep_initial_fil_reserved: Some(TokenAmount::from_whole(1_200_000_000)), - // Taken from here: - solstice_reward_bootstrap: SolsticeRewardBootstrapParams { - swa_timelock_epochs: EPOCHS_IN_HOUR, - consensus_weight_ramp_duration_epochs: 7 * EPOCHS_IN_DAY, - consensus_weight: SOLSTICE_CONSENSUS_WEIGHT, - service_weight: SOLSTICE_SERVICE_WEIGHT, - swa_actor: None, - sra_actor: None, - initial_orchestrator: None, - }, // Enable after `f3_initial_power_table` is determined and set to avoid GC hell // (state tree of epoch 3_451_774 - 900 has to be present in the database if `f3_initial_power_table` is not set) f3_enabled: true, @@ -456,18 +383,6 @@ impl ChainConfig { fip0081_ramp_duration_epochs: env_or_default(ENV_PLEDGE_RULE_RAMP, 200), // FIP-0100: 300M -> 1.4B FIL upgrade_teep_initial_fil_reserved: Some(TokenAmount::from_whole(1_400_000_000)), - // Same as the Lotus 2k network: . - // The reward actor rejects the burnt-funds orchestrator as stored state while - // go-state-types accepts it; see the reward migration tests. - solstice_reward_bootstrap: SolsticeRewardBootstrapParams { - swa_timelock_epochs: 50, - consensus_weight_ramp_duration_epochs: 900, - consensus_weight: SOLSTICE_CONSENSUS_WEIGHT, - service_weight: SOLSTICE_SERVICE_WEIGHT, - swa_actor: Some(Address::SYSTEM_ACTOR), - sra_actor: Some(Address::SYSTEM_ACTOR), - initial_orchestrator: Some(Address::BURNT_FUNDS_ACTOR), - }, f3_enabled: false, f3_consensus: false, f3_bootstrap_epoch: -1, @@ -504,16 +419,6 @@ impl ChainConfig { ), // FIP-0100: 300M -> 1.6B FIL upgrade_teep_initial_fil_reserved: Some(TokenAmount::from_whole(1_600_000_000)), - // Take from here - solstice_reward_bootstrap: SolsticeRewardBootstrapParams { - swa_timelock_epochs: 7 * EPOCHS_IN_DAY, - consensus_weight_ramp_duration_epochs: 9 * 90 * EPOCHS_IN_DAY, - consensus_weight: SOLSTICE_CONSENSUS_WEIGHT, - service_weight: SOLSTICE_SERVICE_WEIGHT, - swa_actor: None, - sra_actor: None, - initial_orchestrator: None, - }, f3_enabled: true, f3_consensus: true, f3_bootstrap_epoch: 1000, diff --git a/src/state_migration/nv29/market.rs b/src/state_migration/nv29/market.rs index be6b01ff4063..972e41c2dc5b 100644 --- a/src/state_migration/nv29/market.rs +++ b/src/state_migration/nv29/market.rs @@ -88,6 +88,7 @@ mod tests { }; // `State` has no `PartialEq`. assert_eq!(format!("{out_state:?}"), format!("{expected:?}")); + assert_eq!(store.put_cbor_default(&out_state).unwrap(), output.new_head); // The v19 tuple is one field shorter, so it no longer decodes as v18. assert!(store.get_cbor::(&output.new_head).is_err()); } diff --git a/src/state_migration/nv29/migration.rs b/src/state_migration/nv29/migration.rs index 4b354a256c67..9925d1e236cc 100644 --- a/src/state_migration/nv29/migration.rs +++ b/src/state_migration/nv29/migration.rs @@ -5,8 +5,9 @@ use super::market::MarketMigrator; use super::reward::RewardMigrator; +use super::reward_bootstrap::SolsticeRewardBootstrapParams; use super::{SystemStateOld, system, verifier::Verifier}; -use crate::networks::{ChainConfig, Height, SolsticeRewardBootstrapParams}; +use crate::networks::{ChainConfig, Height}; use crate::prelude::*; use crate::shim::{ address::Address, @@ -23,8 +24,7 @@ impl StateMigration { store: &BS, state: &Cid, new_manifest: &BuiltinActorManifest, - reward_bootstrap: &SolsticeRewardBootstrapParams, - activation_epoch: ChainEpoch, + chain_config: &ChainConfig, ) -> anyhow::Result<()> { let state_tree = StateTree::new_from_root(store, state)?; let system_actor = state_tree.get_required_actor(&Address::SYSTEM_ACTOR)?; @@ -44,10 +44,12 @@ impl StateMigration { current_manifest.get_system(), system::system_migrator(new_manifest), ); + // Streams start at the first epoch executed on the migrated state. + let activation_epoch = chain_config.epoch(Height::Solstice) + 1; self.add_migrator( current_manifest.get(BuiltinActor::Reward)?, Arc::new(RewardMigrator::new( - reward_bootstrap, + &SolsticeRewardBootstrapParams::for_chain(&chain_config.network), activation_epoch, new_manifest.get(BuiltinActor::Reward)?, )?), @@ -90,15 +92,7 @@ where let new_manifest = BuiltinActorManifest::load_manifest(blockstore, new_manifest_cid)?; let mut migration = StateMigration::::new(Some(verifier)); - // The bootstrap streams start at the first epoch executed on the migrated state, like - // go-state-types' `activationEpoch := priorEpoch + 1`. - migration.add_nv29_migrations( - blockstore, - state, - &new_manifest, - &chain_config.solstice_reward_bootstrap, - epoch + 1, - )?; + migration.add_nv29_migrations(blockstore, state, &new_manifest, chain_config)?; let actors_in = StateTree::new_from_root(blockstore, state)?; let actors_out = StateTree::new(blockstore, StateTreeVersion::V5)?; diff --git a/src/state_migration/nv29/mod.rs b/src/state_migration/nv29/mod.rs index 85499de7ada1..f6c2d0c9231e 100644 --- a/src/state_migration/nv29/mod.rs +++ b/src/state_migration/nv29/mod.rs @@ -5,6 +5,7 @@ mod market; mod migration; mod reward; +mod reward_bootstrap; /// Run migration for `NV29`. This should be the only exported method in this /// module. diff --git a/src/state_migration/nv29/reward.rs b/src/state_migration/nv29/reward.rs index dca8e694a59f..f2f643fdbb96 100644 --- a/src/state_migration/nv29/reward.rs +++ b/src/state_migration/nv29/reward.rs @@ -4,13 +4,10 @@ //! Reward actor migration for FIP-0118: keeps the reward accounting, drops the stored reward //! totals and installs the bootstrap streams and the stream weight authority (SWA). //! -//! Ports the go-state-types migrator and the Lotus stream derivation; the built streams are -//! vetted by the actor crate's own `validate_streams_state`, the check the actor repeats on -//! every block reward: -//! -//! +//! Reference: +//! and . -use crate::networks::{SolsticeRewardBootstrapParams, SolsticeRewardWeightParams}; +use super::reward_bootstrap::{SolsticeRewardBootstrapParams, SolsticeRewardWeightParams}; use crate::shim::address::{Address, Protocol}; use crate::state_migration::common::{ActorMigration, ActorMigrationInput, ActorMigrationOutput}; use crate::utils::db::CborStoreExt as _; @@ -18,8 +15,9 @@ use anyhow::{Context as _, ensure}; use cid::Cid; use fil_actor_reward_state::v18::State as RewardStateOld; use fil_actor_reward_state::v19::{ - DENOM, ExplicitDistribution, RecipientShare, State as RewardStateNew, Stream, StreamAccrual, - StreamId, StreamsState, WeightRecord, validate_streams_state, + DENOM, DistributionInit, ExplicitDistribution, RecipientShare, RecipientTable, + RegisterStreamParams, State as RewardStateNew, Stream, StreamAccrual, StreamId, StreamsState, + WeightRecord, validate_streams_state, }; use fil_actors_shared::v19::builtin::reward::smooth::FilterEstimate; use fvm_ipld_blockstore::Blockstore; @@ -31,7 +29,7 @@ use num_traits::Zero as _; const CONSENSUS_STREAM_ID: StreamId = 1; const SERVICE_STREAM_ID: StreamId = 2; -/// Consensus-only bootstrap: the whole block reward keeps flowing to block producers. +/// Consensus-only bootstrap for a network without service contracts. const NEUTRAL_CONSENSUS_WEIGHT: SolsticeRewardWeightParams = SolsticeRewardWeightParams { v_start: DENOM, floor: DENOM, @@ -52,21 +50,21 @@ pub struct RewardMigrator { } impl RewardMigrator { - /// Derives the bootstrap streams starting at `activation_epoch`, the first epoch executed on - /// the migrated state, and vets them with the actor's own state validation. + /// Derives and validates the bootstrap streams starting at `activation_epoch`, the first + /// epoch executed on the migrated state. /// /// # Errors - /// Fails on the inputs Lotus and go-state-types reject: a missing or non-ID bootstrap - /// address, a negative SWA timelock, a negative ramp, a zero ramp with anything but the - /// neutral weights, or weights that do not start at `DENOM` together, leave their bounds or - /// exceed `DENOM` later. + /// The params do not describe a valid bootstrap: a missing or non-ID address, a negative + /// timelock or ramp, or weights out of bounds. pub fn new( params: &SolsticeRewardBootstrapParams, activation_epoch: ChainEpoch, new_code_cid: Cid, ) -> anyhow::Result { - let (streams, accrued) = bootstrap_streams(params, activation_epoch)?; - validate_streams_state(&streams, &accrued, activation_epoch)?; + let (streams, accrued) = validate_migration_streams( + &bootstrap_streams(params, activation_epoch)?, + activation_epoch, + )?; ensure!(params.swa_timelock_epochs >= 0, "SWA timelock is negative"); let swa_actor = required_address(params.swa_actor, "SWA actor")?; ensure!( @@ -84,14 +82,12 @@ impl RewardMigrator { } } -/// Bootstrap streams and their accruals as Lotus derives them: the consensus stream alone at -/// constant `DENOM` for a zero ramp, otherwise consensus and service streams trading weight at -/// the same rate until the consensus stream reaches its floor, with one zero accrual for the -/// service stream. +/// The streams to register: consensus alone at constant `DENOM` for a zero ramp, otherwise +/// consensus and service trading weight at the same rate. fn bootstrap_streams( params: &SolsticeRewardBootstrapParams, activation_epoch: ChainEpoch, -) -> anyhow::Result<(StreamsState, Vec)> { +) -> anyhow::Result> { let record = |weight: SolsticeRewardWeightParams, slope: i64| WeightRecord { v_start: weight.v_start, slope, @@ -100,66 +96,139 @@ fn bootstrap_streams( cap: weight.cap, }; - let streams = if params.consensus_weight_ramp_duration_epochs == 0 { + if params.consensus_weight_ramp_duration_epochs == 0 { ensure!( params.consensus_weight == NEUTRAL_CONSENSUS_WEIGHT && params.service_weight == NO_SERVICE_WEIGHT, "zero-duration Solstice bootstrap must have constant DENOM consensus weight and zero service weight" ); - vec![Stream { + return Ok(vec![RegisterStreamParams { id: CONSENSUS_STREAM_ID, weight: record(params.consensus_weight, 0), distribution: None, - }] - } else { - let slope = consensus_weight_slope( - params.consensus_weight, - params.consensus_weight_ramp_duration_epochs, - )?; - // The actor accepts weights that start below `DENOM` (the rest burns); go-state-types - // does not, so Lotus would refuse such a bootstrap. + activation_epoch, + }]); + } + + let slope = consensus_weight_slope( + params.consensus_weight, + params.consensus_weight_ramp_duration_epochs, + )?; + let sra_actor = required_address(params.sra_actor, "SRA actor")?; + let initial_orchestrator = + required_address(params.initial_orchestrator, "initial orchestrator")?; + Ok(vec![ + RegisterStreamParams { + id: CONSENSUS_STREAM_ID, + weight: record(params.consensus_weight, -slope), + distribution: None, + activation_epoch, + }, + RegisterStreamParams { + id: SERVICE_STREAM_ID, + weight: record(params.service_weight, slope), + distribution: Some(DistributionInit { + writer: sra_actor, + shares: vec![RecipientShare { + recipient: initial_orchestrator, + share: DENOM, + }], + }), + activation_epoch, + }, + ]) +} + +/// Builds the streams a network upgrade installs and validates them with the actor crate: +/// stream 1 alone at constant `DENOM`, or streams 1 and 2 with equal and opposite slopes, +/// starting weights summing to `DENOM` and one full-share recipient. +/// +fn validate_migration_streams( + params: &[RegisterStreamParams], + activation_epoch: ChainEpoch, +) -> anyhow::Result<(StreamsState, Vec)> { + ensure!( + params.len() == 1 || params.len() == 2, + "bootstrap requires one or two streams" + ); + for param in params { + ensure!( + param.activation_epoch == activation_epoch, + "stream {} activation epoch {} does not match upgrade epoch {activation_epoch}", + param.id, + param.activation_epoch + ); + ensure!( + param.weight.t_start == activation_epoch, + "stream {} weight start {} does not match upgrade epoch {activation_epoch}", + param.id, + param.weight.t_start + ); + } + + if let [consensus] = params { + let neutral = WeightRecord { + v_start: DENOM, + slope: 0, + t_start: activation_epoch, + floor: DENOM, + cap: DENOM, + }; ensure!( - params.consensus_weight.v_start <= DENOM - && params.service_weight.v_start == DENOM - params.consensus_weight.v_start, + consensus.id == 1 && consensus.distribution.is_none() && consensus.weight == neutral, + "single-stream bootstrap must be implicit stream 1 at constant DENOM" + ); + } else if let [consensus, explicit] = params { + ensure!( + consensus.id == 1 && explicit.id == 2, + "split bootstrap stream IDs must be 1 and 2" + ); + let distribution = match (&consensus.distribution, &explicit.distribution) { + (None, Some(distribution)) => distribution, + _ => anyhow::bail!("split bootstrap distribution forms are invalid"), + }; + ensure!( + consensus.weight.v_start <= DENOM + && explicit.weight.v_start == DENOM - consensus.weight.v_start, "bootstrap starting weights must sum to denominator" ); - let sra_actor = required_address(params.sra_actor, "SRA actor")?; - let initial_orchestrator = - required_address(params.initial_orchestrator, "initial orchestrator")?; - vec![ - Stream { - id: CONSENSUS_STREAM_ID, - weight: record(params.consensus_weight, -slope), - distribution: None, - }, - Stream { - id: SERVICE_STREAM_ID, - weight: record(params.service_weight, slope), - distribution: Some(ExplicitDistribution { - writer: sra_actor, - shares: vec![RecipientShare { - recipient: initial_orchestrator, - share: DENOM, - }], - payable: Vec::new(), - claimed_period: Vec::new(), - }), - }, - ] - }; - let accrued = streams - .iter() - .filter(|stream| stream.distribution.is_some()) - .map(|stream| StreamAccrual { - id: stream.id, - amount: TokenAmount::zero(), - }) - .collect(); - let streams = StreamsState { - streams, - tombstones: Vec::new(), - pending_writes: Vec::new(), - }; + ensure!( + consensus.weight.slope < 0 + && explicit.weight.slope > 0 + && consensus.weight.slope == -explicit.weight.slope, + "bootstrap weight slopes are invalid" + ); + ensure!( + matches!(distribution.shares.as_slice(), [share] if share.share == DENOM), + "explicit bootstrap requires one full-share recipient" + ); + } + + let mut streams = StreamsState::default(); + let mut accrued = Vec::new(); + for param in params { + let distribution = param + .distribution + .as_ref() + .map(|init| ExplicitDistribution { + writer: init.writer, + shares: init.shares.clone(), + payable: RecipientTable::default(), + claimed_period: RecipientTable::default(), + }); + if distribution.is_some() { + accrued.push(StreamAccrual { + id: param.id, + amount: TokenAmount::zero(), + }); + } + streams.streams.push(Stream { + id: param.id, + weight: param.weight.clone(), + distribution, + }); + } + validate_streams_state(&streams, &accrued, activation_epoch)?; Ok((streams, accrued)) } @@ -184,8 +253,6 @@ fn consensus_weight_slope( .with_context(|| format!("Solstice consensus weight ramp produces invalid slope {slope}")) } -/// Lotus passes an unset address through as `address.Undef` and lets the ID check reject it; -/// Forest models unset as `None` and names the missing input. fn required_address(address: Option
, name: &str) -> anyhow::Result { let address = address.with_context(|| { format!("{name} is not set: the Solstice migration needs its f0 address") @@ -234,11 +301,11 @@ impl ActorMigration for RewardMigrator { mod tests { use super::*; use crate::db::MemoryDB; - use crate::networks::{ChainConfig, Height, UPGRADE_HEIGHT_UNSCHEDULED}; + use crate::networks::{ChainConfig, Height, NetworkChain, UPGRADE_HEIGHT_UNSCHEDULED}; use crate::utils::cid::CidCborExt as _; use fil_actors_shared::v18::builtin::reward::smooth::FilterEstimate as FilterEstimateOld; - const PERCENT: u64 = DENOM / 100; + use super::super::reward_bootstrap::PERCENT; fn weight(v_start: u64, floor: u64, cap: u64) -> SolsticeRewardWeightParams { SolsticeRewardWeightParams { @@ -321,13 +388,13 @@ mod tests { recipient: Address_v4::new_id(102), share: DENOM, }], - payable: vec![], - claimed_period: vec![], + payable: RecipientTable::default(), + claimed_period: RecipientTable::default(), }), }, ], tombstones: vec![], - pending_writes: vec![], + pending_writes_queue: vec![], }; let out_state: RewardStateNew = store.get_cbor_required(&output.new_head).unwrap(); assert_eq!( @@ -392,7 +459,7 @@ mod tests { distribution: None, }], tombstones: vec![], - pending_writes: vec![], + pending_writes_queue: vec![], } ); assert!(migrator.accrued.is_empty()); @@ -534,8 +601,97 @@ mod tests { } } - // The bootstrap names contracts that must exist before the upgrade runs, so a scheduled - // height and complete addresses go together. + #[test] + fn rejects_malformed_bootstrap_streams() { + type Damage = fn(&mut Vec); + let cases: [(&str, Damage, &str); 11] = [ + ( + "three streams", + |p| p.push(p.last().cloned().unwrap()), + "bootstrap requires one or two streams", + ), + ( + "activation epoch mismatch", + |p| p.first_mut().unwrap().activation_epoch += 1, + "activation epoch 101 does not match upgrade epoch 100", + ), + ( + "weight start mismatch", + |p| p.first_mut().unwrap().weight.t_start += 1, + "weight start 101 does not match upgrade epoch 100", + ), + ( + "single stream that is not neutral", + |p| p.truncate(1), + "single-stream bootstrap must be implicit stream 1 at constant DENOM", + ), + ( + "service stream ID 3", + |p| p.last_mut().unwrap().id = 3, + "split bootstrap stream IDs must be 1 and 2", + ), + ( + "two implicit streams", + |p| p.last_mut().unwrap().distribution = None, + "split bootstrap distribution forms are invalid", + ), + ( + "starting weights under-sum", + |p| p.last_mut().unwrap().weight.v_start -= 1, + "bootstrap starting weights must sum to denominator", + ), + ( + "unequal slopes", + |p| p.last_mut().unwrap().weight.slope += 1, + "bootstrap weight slopes are invalid", + ), + ( + "partial share", + |p| { + let distribution = p.last_mut().unwrap().distribution.as_mut().unwrap(); + distribution.shares.first_mut().unwrap().share -= 1; + }, + "explicit bootstrap requires one full-share recipient", + ), + ( + "delegated writer", + |p| { + let distribution = p.last_mut().unwrap().distribution.as_mut().unwrap(); + distribution.writer = Address_v4::new_delegated(10, &[1]).unwrap(); + }, + "distribution writer f410", + ), + ( + "service cap above what the consensus floor leaves", + |p| p.last_mut().unwrap().weight.cap = 60 * PERCENT, + "stream weights exceed DENOM", + ), + ]; + + for (case, damage, expected_error) in cases { + let mut params = bootstrap_streams(&bootstrap_params(), 100).unwrap(); + damage(&mut params); + let error = validate_migration_streams(¶ms, 100) + .err() + .unwrap_or_else(|| panic!("{case}: accepted")); + assert!( + format!("{error:#}").contains(expected_error), + "{case}: {error:#}" + ); + } + } + + #[test] + fn accepts_alternative_bootstrap_weights() { + let params = SolsticeRewardBootstrapParams { + consensus_weight: weight(80, 60, 80), + service_weight: weight(20, 10, 20), + ..bootstrap_params() + }; + + RewardMigrator::new(¶ms, 100, Cid::default()).unwrap(); + } + #[test] fn scheduled_networks_have_complete_bootstrap_addresses() { for config in [ @@ -544,28 +700,29 @@ mod tests { ChainConfig::butterflynet(), ] { let solstice_epoch = config.epoch(Height::Solstice); - let scheduled = solstice_epoch != UPGRADE_HEIGHT_UNSCHEDULED; - let bootstrap = RewardMigrator::new( - &config.solstice_reward_bootstrap, + if solstice_epoch == UPGRADE_HEIGHT_UNSCHEDULED { + continue; + } + RewardMigrator::new( + &SolsticeRewardBootstrapParams::for_chain(&config.network), solstice_epoch + 1, Cid::default(), - ); - assert_eq!( - bootstrap.is_ok(), - scheduled, - "{}: schedule Solstice only once SWA, SRA and orchestrator have f0 addresses", - config.network - ); + ) + .unwrap_or_else(|e| { + panic!( + "{}: scheduled without a valid bootstrap: {e:#}", + config.network + ) + }); } } - // Lotus 2k names the burnt-funds actor as orchestrator. The reward actor rejects that as - // stored state and pays no block reward on it, while go-state-types accepts it; Forest - // follows the actor. Re-sync the devnet params once upstream agrees. + // The devnet copies the Lotus 2k orchestrator, the burnt-funds actor, which the reward actor + // rejects as a stored recipient; re-sync once upstream settles it. #[test] fn devnet_bootstrap_is_rejected_until_upstream_agrees_on_the_orchestrator() { let error = RewardMigrator::new( - &ChainConfig::devnet().solstice_reward_bootstrap, + &SolsticeRewardBootstrapParams::for_chain(&NetworkChain::Devnet("devnet".into())), 1, Cid::default(), ) @@ -577,23 +734,21 @@ mod tests { ); } - // Only the addresses are missing on the public networks; their timelocks, ramps and weights - // already pass the checks. #[test] fn public_network_params_are_valid_once_addresses_are_set() { - for config in [ - ChainConfig::mainnet(), - ChainConfig::calibnet(), - ChainConfig::butterflynet(), + for chain in [ + NetworkChain::Mainnet, + NetworkChain::Calibnet, + NetworkChain::Butterflynet, ] { let params = SolsticeRewardBootstrapParams { swa_actor: Some(Address::new_id(100)), sra_actor: Some(Address::new_id(101)), initial_orchestrator: Some(Address::new_id(102)), - ..config.solstice_reward_bootstrap + ..SolsticeRewardBootstrapParams::for_chain(&chain) }; RewardMigrator::new(¶ms, 1, Cid::default()) - .unwrap_or_else(|e| panic!("{}: {e:#}", config.network)); + .unwrap_or_else(|e| panic!("{chain}: {e:#}")); } } } diff --git a/src/state_migration/nv29/reward_bootstrap.rs b/src/state_migration/nv29/reward_bootstrap.rs new file mode 100644 index 000000000000..f6d2b580170b --- /dev/null +++ b/src/state_migration/nv29/reward_bootstrap.rs @@ -0,0 +1,86 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! Per-network reward bootstrap for FIP-0118, the only network-specific input of the Solstice +//! migration. Values from the Lotus `params_.go` files at +//! . + +use crate::networks::NetworkChain; +use crate::shim::address::Address; +use crate::shim::clock::{ChainEpoch, EPOCHS_IN_DAY, EPOCHS_IN_HOUR}; +use fil_actor_reward_state::v19::DENOM; + +/// The network's input to the reward migration: timelock, ramp, weights and contracts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SolsticeRewardBootstrapParams { + /// Delay before a stream weight authority (SWA) write takes effect. + pub swa_timelock_epochs: ChainEpoch, + /// Epochs over which the consensus stream weight ramps down from its start to its floor. + /// Zero installs the consensus stream alone at constant `DENOM`. + pub consensus_weight_ramp_duration_epochs: ChainEpoch, + pub consensus_weight: SolsticeRewardWeightParams, + pub service_weight: SolsticeRewardWeightParams, + /// Stream weight authority contract, `None` until it is deployed and has an `f0` address. + pub swa_actor: Option
, + /// Service reward authority contract, the writer of the service stream's shares. + pub sra_actor: Option
, + /// Sole initial recipient of the service stream. + pub initial_orchestrator: Option
, +} + +/// A clamped linear stream weight in `DENOM` fixed point, without its slope and start epoch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SolsticeRewardWeightParams { + pub v_start: u64, + pub floor: u64, + pub cap: u64, +} + +pub(super) const PERCENT: u64 = DENOM / 100; + +/// The FIP-0118 weights, with the timelock, ramp and contract addresses filled in per network. +const FIP0118: SolsticeRewardBootstrapParams = SolsticeRewardBootstrapParams { + swa_timelock_epochs: 0, + consensus_weight_ramp_duration_epochs: 0, + consensus_weight: SolsticeRewardWeightParams { + v_start: 95 * PERCENT, + floor: 50 * PERCENT, + cap: 95 * PERCENT, + }, + service_weight: SolsticeRewardWeightParams { + v_start: 5 * PERCENT, + floor: 5 * PERCENT, + cap: 10 * PERCENT, + }, + swa_actor: None, + sra_actor: None, + initial_orchestrator: None, +}; + +impl SolsticeRewardBootstrapParams { + /// The bootstrap of `chain`, with the contract addresses unset where none is deployed. + pub fn for_chain(chain: &NetworkChain) -> Self { + match chain { + NetworkChain::Mainnet | NetworkChain::Butterflynet => Self { + swa_timelock_epochs: 7 * EPOCHS_IN_DAY, + consensus_weight_ramp_duration_epochs: 9 * 90 * EPOCHS_IN_DAY, + ..FIP0118 + }, + NetworkChain::Calibnet => Self { + swa_timelock_epochs: EPOCHS_IN_HOUR, + consensus_weight_ramp_duration_epochs: 7 * EPOCHS_IN_DAY, + ..FIP0118 + }, + // The burnt-funds orchestrator copies the Lotus 2k network; the reward actor rejects + // it as a stored recipient, see the reward migration tests. + NetworkChain::Devnet(_) => Self { + swa_timelock_epochs: 50, + consensus_weight_ramp_duration_epochs: 900, + swa_actor: Some(Address::SYSTEM_ACTOR), + sra_actor: Some(Address::SYSTEM_ACTOR), + initial_orchestrator: Some(Address::BURNT_FUNDS_ACTOR), + ..FIP0118 + }, + } + } +} From 389957f513f011241e17ffd3294b7dc775861aa2 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Tue, 15 Sep 2026 16:45:13 +0530 Subject: [PATCH 5/9] add more validations for the reward migration --- src/state_migration/nv29/migration.rs | 18 +++-- src/state_migration/nv29/reward.rs | 96 ++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 7 deletions(-) diff --git a/src/state_migration/nv29/migration.rs b/src/state_migration/nv29/migration.rs index 9925d1e236cc..d42b81f4126e 100644 --- a/src/state_migration/nv29/migration.rs +++ b/src/state_migration/nv29/migration.rs @@ -46,13 +46,21 @@ impl StateMigration { ); // Streams start at the first epoch executed on the migrated state. let activation_epoch = chain_config.epoch(Height::Solstice) + 1; + let reward_migrator = RewardMigrator::new( + &SolsticeRewardBootstrapParams::for_chain(&chain_config.network), + activation_epoch, + new_manifest.get(BuiltinActor::Reward)?, + )?; + reward_migrator + .validate_recipients( + &state_tree, + current_manifest.get(BuiltinActor::PaymentChannel).ok(), + ) + .context("invalid reward migration recipients")?; + // The output depends on priorEpoch as well as the actor head. self.add_migrator( current_manifest.get(BuiltinActor::Reward)?, - Arc::new(RewardMigrator::new( - &SolsticeRewardBootstrapParams::for_chain(&chain_config.network), - activation_epoch, - new_manifest.get(BuiltinActor::Reward)?, - )?), + Arc::new(reward_migrator), ); self.add_migrator( current_manifest.get(BuiltinActor::Market)?, diff --git a/src/state_migration/nv29/reward.rs b/src/state_migration/nv29/reward.rs index f2f643fdbb96..b3d2577b6e6d 100644 --- a/src/state_migration/nv29/reward.rs +++ b/src/state_migration/nv29/reward.rs @@ -4,11 +4,12 @@ //! Reward actor migration for FIP-0118: keeps the reward accounting, drops the stored reward //! totals and installs the bootstrap streams and the stream weight authority (SWA). //! -//! Reference: +//! Reference: //! and . use super::reward_bootstrap::{SolsticeRewardBootstrapParams, SolsticeRewardWeightParams}; use crate::shim::address::{Address, Protocol}; +use crate::shim::state_tree::StateTree; use crate::state_migration::common::{ActorMigration, ActorMigrationInput, ActorMigrationOutput}; use crate::utils::db::CborStoreExt as _; use anyhow::{Context as _, ensure}; @@ -80,6 +81,38 @@ impl RewardMigrator { swa_actor, }) } + + /// Checks that every share recipient exists in `actors` and is not a payment channel, which + /// `Collect` deletes and would strand its unpaid rewards. + /// + /// # Errors + /// A recipient is missing or is a payment channel, or `paych_code` is `None` while a stream + /// has recipients. + pub fn validate_recipients( + &self, + actors: &StateTree, + paych_code: Option, + ) -> anyhow::Result<()> { + for stream in &self.streams.streams { + let Some(distribution) = &stream.distribution else { + continue; + }; + let paych_code = paych_code + .context("code cid for payment channel actor not found in old manifest")?; + for share in &distribution.shares { + let recipient = Address::from(share.recipient); + let actor = actors + .get_actor(&recipient) + .with_context(|| format!("failed to load reward recipient {recipient}"))? + .with_context(|| format!("reward recipient {recipient} does not exist"))?; + ensure!( + actor.code != paych_code, + "reward recipient {recipient} is a payment channel" + ); + } + } + Ok(()) + } } /// The streams to register: consensus alone at constant `DENOM` for a zero ramp, otherwise @@ -142,7 +175,6 @@ fn bootstrap_streams( /// Builds the streams a network upgrade installs and validates them with the actor crate: /// stream 1 alone at constant `DENOM`, or streams 1 and 2 with equal and opposite slopes, /// starting weights summing to `DENOM` and one full-share recipient. -/// fn validate_migration_streams( params: &[RegisterStreamParams], activation_epoch: ChainEpoch, @@ -302,8 +334,10 @@ mod tests { use super::*; use crate::db::MemoryDB; use crate::networks::{ChainConfig, Height, NetworkChain, UPGRADE_HEIGHT_UNSCHEDULED}; + use crate::shim::state_tree::{ActorState, StateTreeVersion}; use crate::utils::cid::CidCborExt as _; use fil_actors_shared::v18::builtin::reward::smooth::FilterEstimate as FilterEstimateOld; + use std::sync::Arc; use super::super::reward_bootstrap::PERCENT; @@ -751,4 +785,62 @@ mod tests { .unwrap_or_else(|e| panic!("{chain}: {e:#}")); } } + + #[test] + fn rejects_recipients_that_are_missing_or_payment_channels() { + let account_code = Cid::from_cbor_blake2b256(&"account code").unwrap(); + let paych_code = Cid::from_cbor_blake2b256(&"paych code").unwrap(); + let orchestrator = Address::new_id(102); + let state_tree_with = |code: Option| { + let mut actors = + StateTree::new(&Arc::new(MemoryDB::default()), StateTreeVersion::V5).unwrap(); + if let Some(code) = code { + let actor = + ActorState::new(code, Cid::default(), TokenAmount::zero().into(), 0, None); + actors.set_actor(&orchestrator, actor).unwrap(); + } + actors + }; + let split = RewardMigrator::new(&bootstrap_params(), 100, Cid::default()).unwrap(); + let neutral = SolsticeRewardBootstrapParams { + consensus_weight_ramp_duration_epochs: 0, + consensus_weight: NEUTRAL_CONSENSUS_WEIGHT, + service_weight: NO_SERVICE_WEIGHT, + ..bootstrap_params() + }; + let neutral = RewardMigrator::new(&neutral, 100, Cid::default()).unwrap(); + + let cases = [ + (&split, Some(account_code), Some(paych_code), Ok(())), + (&neutral, None, None, Ok(())), + ( + &split, + None, + Some(paych_code), + Err("reward recipient f0102 does not exist"), + ), + ( + &split, + Some(paych_code), + Some(paych_code), + Err("reward recipient f0102 is a payment channel"), + ), + ( + &split, + Some(account_code), + None, + Err("code cid for payment channel actor not found in old manifest"), + ), + ]; + for (migrator, recipient_code, paych_code, expected) in cases { + let result = migrator.validate_recipients(&state_tree_with(recipient_code), paych_code); + match expected { + Ok(()) => result.unwrap(), + Err(message) => { + let error = format!("{:#}", result.unwrap_err()); + assert!(error.contains(message), "{error}"); + } + } + } + } } From 09e211592a7c00f9600945bfca01a5c9c453835f Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Wed, 16 Sep 2026 20:27:33 +0530 Subject: [PATCH 6/9] port more changes --- src/rpc/methods/state.rs | 29 ++++++++++++------- .../forest__rpc__tests__rpc__v0.snap | 2 +- .../forest__rpc__tests__rpc__v1.snap | 2 +- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/rpc/methods/state.rs b/src/rpc/methods/state.rs index a30db0b1e429..097439178192 100644 --- a/src/rpc/methods/state.rs +++ b/src/rpc/methods/state.rs @@ -1108,7 +1108,7 @@ impl RpcMethod<3> for StateMinerInitialPledgeCollateral { const PARAM_NAMES: [&'static str; 3] = ["minerAddress", "sectorPreCommitInfo", "tipsetKey"]; const API_PATHS: BitFlags = ApiPaths::all(); const PERMISSION: Permission = Permission::Read; - const DESCRIPTION: &'static str = "Returns the initial pledge collateral for the specified miner's sector. Deprecated: from NV29 (FIP-0118) every sector gets maximum quality-adjusted power regardless of its deals, so the value is far too low; use StateMinerInitialPledgeForSector instead."; + const DESCRIPTION: &'static str = "Returns the initial pledge collateral for the specified miner's sector. Deprecated: from NV29 (FIP-0118) every sector gets maximum quality-adjusted power regardless of its deals, so the deal IDs are ignored; use StateMinerInitialPledgeForSector instead."; type Params = (Address, SectorPreCommitInfo, ApiTipsetKey); type Ok = TokenAmount; @@ -1125,17 +1125,24 @@ impl RpcMethod<3> for StateMinerInitialPledgeCollateral { .sector_size() .map_err(|e| anyhow::anyhow!("failed to get resolve size: {e}"))?; - let market_state: market::State = ctx.state_manager.get_actor_state(&ts)?; - let (w, vw) = market_state.verify_deals_for_activation( - ctx.db(), - address, - pci.deal_ids, - ts.epoch(), - pci.expiration, - )?; - let duration = sector_duration_from_expiration(pci.expiration, ts.epoch())?; + let sector_size = SectorSize::from(sector_size).into(); let sector_weight = - qa_power_for_weight(SectorSize::from(sector_size).into(), duration, &w, &vw); + if ctx.state_manager.get_network_version(ts.epoch()) >= NetworkVersion::V29 { + // Every sector holds maximum quality-adjusted power, which deal weight does not + // describe. + qa_power_max(sector_size) + } else { + let market_state: market::State = ctx.state_manager.get_actor_state(&ts)?; + let (w, vw) = market_state.verify_deals_for_activation( + ctx.db(), + address, + pci.deal_ids, + ts.epoch(), + pci.expiration, + )?; + let duration = sector_duration_from_expiration(pci.expiration, ts.epoch())?; + qa_power_for_weight(sector_size, duration, &w, &vw) + }; let initial_pledge = compute_initial_pledge_for_power(&ctx, &ts, §or_weight)?; diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap index 3f62dbf7c128..478af5bf83eb 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap @@ -3373,7 +3373,7 @@ methods: $ref: "#/components/schemas/MinerInfo" paramStructure: by-position - name: Filecoin.StateMinerInitialPledgeCollateral - description: "Returns the initial pledge collateral for the specified miner's sector. Deprecated: from NV29 (FIP-0118) every sector gets maximum quality-adjusted power regardless of its deals, so the value is far too low; use StateMinerInitialPledgeForSector instead." + description: "Returns the initial pledge collateral for the specified miner's sector. Deprecated: from NV29 (FIP-0118) every sector gets maximum quality-adjusted power regardless of its deals, so the deal IDs are ignored; use StateMinerInitialPledgeForSector instead." params: - name: minerAddress required: true diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap index ea970d96aaaa..d4bc64d82113 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap @@ -3431,7 +3431,7 @@ methods: $ref: "#/components/schemas/MinerInfo" paramStructure: by-position - name: Filecoin.StateMinerInitialPledgeCollateral - description: "Returns the initial pledge collateral for the specified miner's sector. Deprecated: from NV29 (FIP-0118) every sector gets maximum quality-adjusted power regardless of its deals, so the value is far too low; use StateMinerInitialPledgeForSector instead." + description: "Returns the initial pledge collateral for the specified miner's sector. Deprecated: from NV29 (FIP-0118) every sector gets maximum quality-adjusted power regardless of its deals, so the deal IDs are ignored; use StateMinerInitialPledgeForSector instead." params: - name: minerAddress required: true From 3bb81f37b3018c505d1a1058ea3ec4e575cd0a27 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Thu, 17 Sep 2026 17:02:41 +0530 Subject: [PATCH 7/9] update the reward params for all network for nv29 --- src/state_migration/nv29/migration.rs | 4 +- src/state_migration/nv29/reward.rs | 163 ++-------------- src/state_migration/nv29/reward_bootstrap.rs | 195 ++++++++++++++++++- 3 files changed, 211 insertions(+), 151 deletions(-) diff --git a/src/state_migration/nv29/migration.rs b/src/state_migration/nv29/migration.rs index d42b81f4126e..17bdc60ef3e8 100644 --- a/src/state_migration/nv29/migration.rs +++ b/src/state_migration/nv29/migration.rs @@ -46,8 +46,10 @@ impl StateMigration { ); // Streams start at the first epoch executed on the migrated state. let activation_epoch = chain_config.epoch(Height::Solstice) + 1; + let bootstrap = + SolsticeRewardBootstrapParams::for_chain(&chain_config.network).resolve(&state_tree)?; let reward_migrator = RewardMigrator::new( - &SolsticeRewardBootstrapParams::for_chain(&chain_config.network), + &bootstrap, activation_epoch, new_manifest.get(BuiltinActor::Reward)?, )?; diff --git a/src/state_migration/nv29/reward.rs b/src/state_migration/nv29/reward.rs index b3d2577b6e6d..e03696cc83fd 100644 --- a/src/state_migration/nv29/reward.rs +++ b/src/state_migration/nv29/reward.rs @@ -5,7 +5,7 @@ //! totals and installs the bootstrap streams and the stream weight authority (SWA). //! //! Reference: -//! and . +//! and . use super::reward_bootstrap::{SolsticeRewardBootstrapParams, SolsticeRewardWeightParams}; use crate::shim::address::{Address, Protocol}; @@ -333,7 +333,7 @@ impl ActorMigration for RewardMigrator { mod tests { use super::*; use crate::db::MemoryDB; - use crate::networks::{ChainConfig, Height, NetworkChain, UPGRADE_HEIGHT_UNSCHEDULED}; + use crate::networks::{ChainConfig, Height, UPGRADE_HEIGHT_UNSCHEDULED}; use crate::shim::state_tree::{ActorState, StateTreeVersion}; use crate::utils::cid::CidCborExt as _; use fil_actors_shared::v18::builtin::reward::smooth::FilterEstimate as FilterEstimateOld; @@ -352,7 +352,8 @@ mod tests { fn bootstrap_params() -> SolsticeRewardBootstrapParams { SolsticeRewardBootstrapParams { swa_timelock_epochs: 20_160, - consensus_weight_ramp_duration_epochs: 81, + // 45% of the reward moves over 45 epochs: one percent per epoch. + consensus_weight_ramp_duration_epochs: 45, consensus_weight: weight(95, 50, 95), service_weight: weight(5, 5, 10), swa_actor: Some(Address::new_id(100)), @@ -391,78 +392,6 @@ mod tests { .unwrap() .unwrap(); assert_eq!(output.new_code_cid, new_code_cid); - - // 45% of DENOM moves from consensus to service over the 81-epoch ramp, rounded up. - let slope = 5_555_555_555_555_556; - let expected_streams = StreamsState { - streams: vec![ - Stream { - id: 1, - weight: WeightRecord { - v_start: 95 * PERCENT, - slope: -slope, - t_start: activation_epoch, - floor: 50 * PERCENT, - cap: 95 * PERCENT, - }, - distribution: None, - }, - Stream { - id: 2, - weight: WeightRecord { - v_start: 5 * PERCENT, - slope, - t_start: activation_epoch, - floor: 5 * PERCENT, - cap: 10 * PERCENT, - }, - distribution: Some(ExplicitDistribution { - writer: Address_v4::new_id(101), - shares: vec![RecipientShare { - recipient: Address_v4::new_id(102), - share: DENOM, - }], - payable: RecipientTable::default(), - claimed_period: RecipientTable::default(), - }), - }, - ], - tombstones: vec![], - pending_writes_queue: vec![], - }; - let out_state: RewardStateNew = store.get_cbor_required(&output.new_head).unwrap(); - assert_eq!( - store - .get_cbor_required::(&out_state.streams_root) - .unwrap(), - expected_streams - ); - - let expected = RewardStateNew { - cumsum_baseline: 1.into(), - cumsum_realized: 2.into(), - effective_network_time: 3, - effective_baseline_power: 4.into(), - this_epoch_reward: TokenAmount::from_atto(5), - this_epoch_reward_smoothed: FilterEstimate { - position: 6.into(), - velocity: 7.into(), - }, - this_epoch_baseline_power: 8.into(), - epoch: 9, - total_minted_reward: TokenAmount::from_atto(10), - total_burn_minted: TokenAmount::zero(), - total_explicit_minted: TokenAmount::zero(), - accrued: vec![StreamAccrual { - id: 2, - amount: TokenAmount::zero(), - }], - swa_timelock_epochs: 20_160, - swa_actor: Address_v4::new_id(100), - streams_root: store.put_cbor_default(&expected_streams).unwrap(), - }; - // `State` has no `PartialEq`. - assert_eq!(format!("{out_state:?}"), format!("{expected:?}")); } #[test] @@ -499,22 +428,6 @@ mod tests { assert!(migrator.accrued.is_empty()); } - #[test] - fn consensus_weight_slope_rounds_up_to_reach_the_floor_within_the_ramp() { - // (ramp epochs, per-epoch slope): 45% of DENOM spread over the ramp. - for (ramp_epochs, expected_slope) in [ - (900, 500_000_000_000_000), - (81, 5_555_555_555_555_556), - (20_160, 22_321_428_571_429), - (2_332_800, 192_901_234_568), - ] { - assert_eq!( - consensus_weight_slope(weight(95, 50, 95), ramp_epochs).unwrap(), - expected_slope - ); - } - } - #[test] fn rejects_incomplete_or_invalid_bootstrap_params() { let valid = bootstrap_params(); @@ -715,17 +628,6 @@ mod tests { } } - #[test] - fn accepts_alternative_bootstrap_weights() { - let params = SolsticeRewardBootstrapParams { - consensus_weight: weight(80, 60, 80), - service_weight: weight(20, 10, 20), - ..bootstrap_params() - }; - - RewardMigrator::new(¶ms, 100, Cid::default()).unwrap(); - } - #[test] fn scheduled_networks_have_complete_bootstrap_addresses() { for config in [ @@ -737,52 +639,27 @@ mod tests { if solstice_epoch == UPGRADE_HEIGHT_UNSCHEDULED { continue; } - RewardMigrator::new( - &SolsticeRewardBootstrapParams::for_chain(&config.network), - solstice_epoch + 1, - Cid::default(), - ) - .unwrap_or_else(|e| { - panic!( - "{}: scheduled without a valid bootstrap: {e:#}", - config.network - ) - }); - } - } - - // The devnet copies the Lotus 2k orchestrator, the burnt-funds actor, which the reward actor - // rejects as a stored recipient; re-sync once upstream settles it. - #[test] - fn devnet_bootstrap_is_rejected_until_upstream_agrees_on_the_orchestrator() { - let error = RewardMigrator::new( - &SolsticeRewardBootstrapParams::for_chain(&NetworkChain::Devnet("devnet".into())), - 1, - Cid::default(), - ) - .err() - .expect("burnt-funds orchestrator accepted"); - assert!( - format!("{error:#}").contains("burn sentinel persisted as a recipient"), - "{error:#}" - ); - } - - #[test] - fn public_network_params_are_valid_once_addresses_are_set() { - for chain in [ - NetworkChain::Mainnet, - NetworkChain::Calibnet, - NetworkChain::Butterflynet, - ] { + let params = SolsticeRewardBootstrapParams::for_chain(&config.network); + assert!( + params.swa_actor.is_some() + && params.sra_actor.is_some() + && params.initial_orchestrator.is_some(), + "{}: scheduled without all bootstrap addresses", + config.network + ); + // The migration resolves the addresses on chain; stand-ins leave the weights to check. let params = SolsticeRewardBootstrapParams { swa_actor: Some(Address::new_id(100)), sra_actor: Some(Address::new_id(101)), initial_orchestrator: Some(Address::new_id(102)), - ..SolsticeRewardBootstrapParams::for_chain(&chain) + ..params }; - RewardMigrator::new(¶ms, 1, Cid::default()) - .unwrap_or_else(|e| panic!("{chain}: {e:#}")); + RewardMigrator::new(¶ms, solstice_epoch + 1, Cid::default()).unwrap_or_else(|e| { + panic!( + "{}: scheduled without a valid bootstrap: {e:#}", + config.network + ) + }); } } diff --git a/src/state_migration/nv29/reward_bootstrap.rs b/src/state_migration/nv29/reward_bootstrap.rs index f6d2b580170b..2d15ad3a53d0 100644 --- a/src/state_migration/nv29/reward_bootstrap.rs +++ b/src/state_migration/nv29/reward_bootstrap.rs @@ -3,12 +3,17 @@ //! Per-network reward bootstrap for FIP-0118, the only network-specific input of the Solstice //! migration. Values from the Lotus `params_.go` files at -//! . +//! . use crate::networks::NetworkChain; +use crate::rpc::eth::types::EthAddress; use crate::shim::address::Address; use crate::shim::clock::{ChainEpoch, EPOCHS_IN_DAY, EPOCHS_IN_HOUR}; +use crate::shim::state_tree::StateTree; +use anyhow::{Context as _, ensure}; use fil_actor_reward_state::v19::DENOM; +use fvm_ipld_blockstore::Blockstore; +use std::str::FromStr as _; /// The network's input to the reward migration: timelock, ramp, weights and contracts. #[derive(Debug, Clone, PartialEq, Eq)] @@ -20,7 +25,7 @@ pub struct SolsticeRewardBootstrapParams { pub consensus_weight_ramp_duration_epochs: ChainEpoch, pub consensus_weight: SolsticeRewardWeightParams, pub service_weight: SolsticeRewardWeightParams, - /// Stream weight authority contract, `None` until it is deployed and has an `f0` address. + /// Stream weight authority contract, `None` until it is deployed. pub swa_actor: Option
, /// Service reward authority contract, the writer of the service stream's shares. pub sra_actor: Option
, @@ -38,6 +43,11 @@ pub struct SolsticeRewardWeightParams { pub(super) const PERCENT: u64 = DENOM / 100; +/// The consensus weight ramps over nine of the quarters the network's SRA is deployed with. +const RAMP_QUARTERS: ChainEpoch = 9; +/// A quarter of the builtin-actors year, 31_556_925 seconds of 30 second epochs. +const MAINNET_EPOCHS_PER_QUARTER: ChainEpoch = 262_974; + /// The FIP-0118 weights, with the timelock, ramp and contract addresses filled in per network. const FIP0118: SolsticeRewardBootstrapParams = SolsticeRewardBootstrapParams { swa_timelock_epochs: 0, @@ -61,18 +71,26 @@ impl SolsticeRewardBootstrapParams { /// The bootstrap of `chain`, with the contract addresses unset where none is deployed. pub fn for_chain(chain: &NetworkChain) -> Self { match chain { - NetworkChain::Mainnet | NetworkChain::Butterflynet => Self { + NetworkChain::Mainnet => Self { swa_timelock_epochs: 7 * EPOCHS_IN_DAY, - consensus_weight_ramp_duration_epochs: 9 * 90 * EPOCHS_IN_DAY, + consensus_weight_ramp_duration_epochs: RAMP_QUARTERS * MAINNET_EPOCHS_PER_QUARTER, ..FIP0118 }, NetworkChain::Calibnet => Self { swa_timelock_epochs: EPOCHS_IN_HOUR, - consensus_weight_ramp_duration_epochs: 7 * EPOCHS_IN_DAY, + consensus_weight_ramp_duration_epochs: RAMP_QUARTERS * EPOCHS_IN_DAY, + ..FIP0118 + }, + NetworkChain::Butterflynet => Self { + swa_timelock_epochs: 40, + consensus_weight_ramp_duration_epochs: RAMP_QUARTERS * 2 * EPOCHS_IN_HOUR, + swa_actor: Some(evm_address("0x17c43bC9d8E8600ebE7599C18f2dA2D5CED68D95")), + sra_actor: Some(evm_address("0xea340224F4df7D01d2657964215E37452165b0A1")), + initial_orchestrator: Some(evm_address( + "0x48C7DC38e74C9fA9eA6484Ad6Ad0520349dC9B40", + )), ..FIP0118 }, - // The burnt-funds orchestrator copies the Lotus 2k network; the reward actor rejects - // it as a stored recipient, see the reward migration tests. NetworkChain::Devnet(_) => Self { swa_timelock_epochs: 50, consensus_weight_ramp_duration_epochs: 900, @@ -83,4 +101,167 @@ impl SolsticeRewardBootstrapParams { }, } } + + /// Resolves the contract addresses to `f0` addresses against `actors`, the state tree the + /// migration reads. + /// + /// # Errors + /// The SWA is unset, or a set address is not on chain. + pub fn resolve(mut self, actors: &StateTree) -> anyhow::Result { + ensure!( + self.swa_actor.is_some(), + "Solstice bootstrap SWA actor is unset" + ); + // A consensus-only bootstrap leaves the service stream addresses unset. + for (name, address) in [ + ("SWA actor", &mut self.swa_actor), + ("SRA actor", &mut self.sra_actor), + ("initial orchestrator", &mut self.initial_orchestrator), + ] { + let Some(unresolved) = address.take() else { + continue; + }; + let id = actors.lookup_id(&unresolved)?.with_context(|| { + format!("Solstice bootstrap {name} {unresolved} is not on chain") + })?; + *address = Some(Address::new_id(id)); + } + Ok(self) + } +} + +/// An EVM address as Lotus writes it: `f0` for a masked ID, `f410` for anything else. +fn evm_address(hex: &str) -> Address { + EthAddress::from_str(hex) + .expect("hard-coded EVM address is well-formed") + .to_filecoin_address() + .expect("EVM address has a Filecoin form") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::MemoryDB; + use crate::shim::state_tree::{ActorState, StateTreeVersion}; + use crate::utils::cid::CidCborExt as _; + use crate::utils::db::CborStoreExt as _; + use cid::Cid; + use std::sync::Arc; + + /// A state tree whose init actor maps each address to a fresh ID, returned in order. + fn state_tree_with(addresses: &[Address]) -> (StateTree>, Vec
) { + let store = Arc::new(MemoryDB::default()); + let mut init_state = + fil_actor_init_state::v19::State::new(&store, "migrationtest".into()).unwrap(); + let ids = addresses + .iter() + .map(|address| { + let (id, _) = init_state + .map_addresses_to_id(&store, &address.into(), None) + .unwrap(); + Address::new_id(id) + }) + .collect(); + let init_head = store.put_cbor_default(&init_state).unwrap(); + let init_actor = ActorState::new( + Cid::from_cbor_blake2b256(&"init code").unwrap(), + init_head, + Default::default(), + 0, + None, + ); + let mut actors = StateTree::new(&store, StateTreeVersion::V5).unwrap(); + actors.set_actor(&Address::INIT_ACTOR, init_actor).unwrap(); + (actors, ids) + } + + fn params_with( + swa: Option
, + sra: Option
, + orchestrator: Option
, + ) -> SolsticeRewardBootstrapParams { + SolsticeRewardBootstrapParams { + swa_actor: swa, + sra_actor: sra, + initial_orchestrator: orchestrator, + ..FIP0118 + } + } + + fn contract(seed: u8) -> Address { + Address::new_delegated( + Address::ETHEREUM_ACCOUNT_MANAGER_ACTOR.id().unwrap(), + &[seed; 20], + ) + .unwrap() + } + + fn wallet(seed: u8) -> Address { + Address::new_secp256k1(&[seed; 65]).unwrap() + } + + #[test] + fn resolves_contract_and_wallet_addresses_to_ids() { + let (swa, sra, orchestrator) = (contract(1), contract(2), wallet(3)); + let (actors, ids) = state_tree_with(&[swa, sra, orchestrator]); + + let resolved = params_with(Some(swa), Some(sra), Some(orchestrator)) + .resolve(&actors) + .unwrap(); + + assert_eq!( + ( + resolved.swa_actor, + resolved.sra_actor, + resolved.initial_orchestrator + ), + (Some(ids[0]), Some(ids[1]), Some(ids[2])) + ); + } + + #[test] + fn unset_service_stream_addresses_pass_through() { + let swa = contract(1); + let (actors, ids) = state_tree_with(&[swa]); + + let resolved = params_with(Some(swa), None, None).resolve(&actors).unwrap(); + + assert_eq!(resolved.swa_actor, Some(ids[0])); + assert_eq!(resolved.sra_actor, None); + assert_eq!(resolved.initial_orchestrator, None); + } + + #[test] + fn rejects_an_unset_swa() { + let (actors, _) = state_tree_with(&[]); + + let error = params_with(None, Some(contract(2)), Some(wallet(3))) + .resolve(&actors) + .unwrap_err(); + + assert!( + error.to_string().contains("SWA actor is unset"), + "{error:#}" + ); + } + + #[test] + fn rejects_an_address_missing_from_the_state_tree() { + let (swa, sra, orchestrator) = (contract(1), contract(2), wallet(3)); + let cases = [ + ("SWA actor", swa, [sra, orchestrator]), + ("SRA actor", sra, [swa, orchestrator]), + ("initial orchestrator", orchestrator, [swa, sra]), + ]; + for (name, missing, on_chain) in cases { + let (actors, _) = state_tree_with(&on_chain); + + let error = params_with(Some(swa), Some(sra), Some(orchestrator)) + .resolve(&actors) + .unwrap_err(); + + let expected = format!("Solstice bootstrap {name} {missing} is not on chain"); + assert!(error.to_string().contains(&expected), "{error:#}"); + } + } } From 7f7cf8abf5a81c9116b3f8bc316ef834fb7b82a3 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Fri, 18 Sep 2026 18:45:28 +0530 Subject: [PATCH 8/9] fix the burn actor bug and more validation checks --- CHANGELOG.md | 2 + src/rpc/methods/state.rs | 36 +-- src/rpc/methods/state/tests.rs | 33 +++ .../forest__rpc__tests__rpc__v0.snap | 2 +- .../forest__rpc__tests__rpc__v1.snap | 2 +- src/state_migration/nv29/reward.rs | 231 ++++++++++++++---- src/state_migration/nv29/reward_bootstrap.rs | 41 +++- 7 files changed, 272 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a3c2e23838..cfbc5ee33003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ - [#7552](https://github.com/ChainSafe/forest/issues/7552): `Filecoin.StateGetNetworkParams` now reports `UpgradeSolsticeHeight` (NV29) in place of the `UpgradeXxHeight` placeholder, matching Lotus. The upgrade is not scheduled on any network yet. +- [#7599](https://github.com/ChainSafe/forest/pull/7599): `Filecoin.StateMinerInitialPledgeCollateral` returns an error from NV29 (FIP-0118), matching Lotus: a pre-commit no longer describes a pledge. Use `Filecoin.StateMinerInitialPledgeForSector` with the full sector size as the verified size. + ### Removed ### Fixed diff --git a/src/rpc/methods/state.rs b/src/rpc/methods/state.rs index 097439178192..7a1034fa47e2 100644 --- a/src/rpc/methods/state.rs +++ b/src/rpc/methods/state.rs @@ -1108,7 +1108,7 @@ impl RpcMethod<3> for StateMinerInitialPledgeCollateral { const PARAM_NAMES: [&'static str; 3] = ["minerAddress", "sectorPreCommitInfo", "tipsetKey"]; const API_PATHS: BitFlags = ApiPaths::all(); const PERMISSION: Permission = Permission::Read; - const DESCRIPTION: &'static str = "Returns the initial pledge collateral for the specified miner's sector. Deprecated: from NV29 (FIP-0118) every sector gets maximum quality-adjusted power regardless of its deals, so the deal IDs are ignored; use StateMinerInitialPledgeForSector instead."; + const DESCRIPTION: &'static str = "Returns the initial pledge collateral for the specified miner's sector. From NV29 (FIP-0118) it returns an error: every sector gets maximum quality-adjusted power regardless of its deals, so a pre-commit no longer describes a pledge. Use StateMinerInitialPledgeForSector instead."; type Params = (Address, SectorPreCommitInfo, ApiTipsetKey); type Ok = TokenAmount; @@ -1120,29 +1120,29 @@ impl RpcMethod<3> for StateMinerInitialPledgeCollateral { ) -> Result { let ts = ctx.chain_store().load_required_tipset_or_heaviest(&tsk)?; + if ctx.state_manager.get_network_version(ts.epoch()) >= NetworkVersion::V29 { + return Err(anyhow::anyhow!( + "StateMinerInitialPledgeCollateral is unsupported from network version 29 (FIP-0118): use StateMinerInitialPledgeForSector" + ) + .into()); + } + let sector_size = pci .seal_proof .sector_size() .map_err(|e| anyhow::anyhow!("failed to get resolve size: {e}"))?; - let sector_size = SectorSize::from(sector_size).into(); + let market_state: market::State = ctx.state_manager.get_actor_state(&ts)?; + let (w, vw) = market_state.verify_deals_for_activation( + ctx.db(), + address, + pci.deal_ids, + ts.epoch(), + pci.expiration, + )?; + let duration = sector_duration_from_expiration(pci.expiration, ts.epoch())?; let sector_weight = - if ctx.state_manager.get_network_version(ts.epoch()) >= NetworkVersion::V29 { - // Every sector holds maximum quality-adjusted power, which deal weight does not - // describe. - qa_power_max(sector_size) - } else { - let market_state: market::State = ctx.state_manager.get_actor_state(&ts)?; - let (w, vw) = market_state.verify_deals_for_activation( - ctx.db(), - address, - pci.deal_ids, - ts.epoch(), - pci.expiration, - )?; - let duration = sector_duration_from_expiration(pci.expiration, ts.epoch())?; - qa_power_for_weight(sector_size, duration, &w, &vw) - }; + qa_power_for_weight(SectorSize::from(sector_size).into(), duration, &w, &vw); let initial_pledge = compute_initial_pledge_for_power(&ctx, &ts, §or_weight)?; diff --git a/src/rpc/methods/state/tests.rs b/src/rpc/methods/state/tests.rs index 0ee597532dc4..54294ae6e0a6 100644 --- a/src/rpc/methods/state/tests.rs +++ b/src/rpc/methods/state/tests.rs @@ -444,6 +444,39 @@ async fn initial_pledge_collateral_matches_the_sector_pledge() { ); } +/// From NV29 a pre-commit no longer describes a pledge, so the collateral RPC refuses. +#[tokio::test] +async fn initial_pledge_collateral_is_retired_from_nv29() { + let mut config = ChainConfig::calibnet(); + // A height applies to the epochs after it. + config + .height_infos + .get_mut(&Height::Solstice) + .unwrap() + .epoch = FIXTURE_EPOCH - 1; + let (ctx, _) = ctx_at(config, FIXTURE_EPOCH, &Default::default()); + let pre_commit = SectorPreCommitInfo::from(fil_actor_miner_state::v18::SectorPreCommitInfo { + seal_proof: RegisteredSealProofV4::StackedDRG32GiBV1P1, + expiration: FIXTURE_EPOCH + 1_000, + ..Default::default() + }); + + let error = StateMinerInitialPledgeCollateral::handle( + ctx, + (Address::new_id(1000), pre_commit, ApiTipsetKey(None)), + &Default::default(), + ) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("unsupported from network version 29"), + "{error}" + ); +} + #[rstest] #[case::no_power_actor(Address::POWER_ACTOR)] #[case::no_reward_actor(Address::REWARD_ACTOR)] diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap index 478af5bf83eb..bede7948848d 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap @@ -3373,7 +3373,7 @@ methods: $ref: "#/components/schemas/MinerInfo" paramStructure: by-position - name: Filecoin.StateMinerInitialPledgeCollateral - description: "Returns the initial pledge collateral for the specified miner's sector. Deprecated: from NV29 (FIP-0118) every sector gets maximum quality-adjusted power regardless of its deals, so the deal IDs are ignored; use StateMinerInitialPledgeForSector instead." + description: "Returns the initial pledge collateral for the specified miner's sector. From NV29 (FIP-0118) it returns an error: every sector gets maximum quality-adjusted power regardless of its deals, so a pre-commit no longer describes a pledge. Use StateMinerInitialPledgeForSector instead." params: - name: minerAddress required: true diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap index d4bc64d82113..9a8896e13f22 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap @@ -3431,7 +3431,7 @@ methods: $ref: "#/components/schemas/MinerInfo" paramStructure: by-position - name: Filecoin.StateMinerInitialPledgeCollateral - description: "Returns the initial pledge collateral for the specified miner's sector. Deprecated: from NV29 (FIP-0118) every sector gets maximum quality-adjusted power regardless of its deals, so the deal IDs are ignored; use StateMinerInitialPledgeForSector instead." + description: "Returns the initial pledge collateral for the specified miner's sector. From NV29 (FIP-0118) it returns an error: every sector gets maximum quality-adjusted power regardless of its deals, so a pre-commit no longer describes a pledge. Use StateMinerInitialPledgeForSector instead." params: - name: minerAddress required: true diff --git a/src/state_migration/nv29/reward.rs b/src/state_migration/nv29/reward.rs index e03696cc83fd..be9fc85b7bd2 100644 --- a/src/state_migration/nv29/reward.rs +++ b/src/state_migration/nv29/reward.rs @@ -4,8 +4,8 @@ //! Reward actor migration for FIP-0118: keeps the reward accounting, drops the stored reward //! totals and installs the bootstrap streams and the stream weight authority (SWA). //! -//! Reference: -//! and . +//! Reference: +//! and . use super::reward_bootstrap::{SolsticeRewardBootstrapParams, SolsticeRewardWeightParams}; use crate::shim::address::{Address, Protocol}; @@ -82,39 +82,61 @@ impl RewardMigrator { }) } - /// Checks that every share recipient exists in `actors` and is not a payment channel, which - /// `Collect` deletes and would strand its unpaid rewards. + /// Checks the SWA, every distribution writer and every share recipient against `actors`. /// /// # Errors - /// A recipient is missing or is a payment channel, or `paych_code` is `None` while a stream - /// has recipients. + /// `paych_code` is `None`, or a referenced actor is the burn actor, is missing, or is a + /// payment channel. pub fn validate_recipients( &self, actors: &StateTree, paych_code: Option, ) -> anyhow::Result<()> { + let paych_code = + paych_code.context("code cid for payment channel actor not found in old manifest")?; + validate_actor_reference(actors, self.swa_actor, "SWA actor", paych_code)?; for stream in &self.streams.streams { let Some(distribution) = &stream.distribution else { continue; }; - let paych_code = paych_code - .context("code cid for payment channel actor not found in old manifest")?; + validate_actor_reference( + actors, + distribution.writer, + "distribution writer", + paych_code, + )?; for share in &distribution.shares { - let recipient = Address::from(share.recipient); - let actor = actors - .get_actor(&recipient) - .with_context(|| format!("failed to load reward recipient {recipient}"))? - .with_context(|| format!("reward recipient {recipient} does not exist"))?; - ensure!( - actor.code != paych_code, - "reward recipient {recipient} is a payment channel" - ); + validate_actor_reference(actors, share.recipient, "reward recipient", paych_code)?; } } Ok(()) } } +/// Rejects an actor the reward state must not name: the burn actor, one missing from `actors`, +/// or a payment channel, which `Collect` deletes and would strand its unpaid rewards. +fn validate_actor_reference( + actors: &StateTree, + address: Address_v4, + label: &str, + paych_code: Cid, +) -> anyhow::Result<()> { + let address = Address::from(address); + ensure!( + address != Address::BURNT_FUNDS_ACTOR, + "{label} is the burn actor" + ); + let actor = actors + .get_actor(&address) + .with_context(|| format!("failed to load {label} {address}"))? + .with_context(|| format!("{label} {address} does not exist"))?; + ensure!( + actor.code != paych_code, + "{label} {address} is a payment channel" + ); + Ok(()) +} + /// The streams to register: consensus alone at constant `DENOM` for a zero ramp, otherwise /// consensus and service trading weight at the same rate. fn bootstrap_streams( @@ -664,58 +686,165 @@ mod tests { } #[test] - fn rejects_recipients_that_are_missing_or_payment_channels() { - let account_code = Cid::from_cbor_blake2b256(&"account code").unwrap(); - let paych_code = Cid::from_cbor_blake2b256(&"paych code").unwrap(); - let orchestrator = Address::new_id(102); - let state_tree_with = |code: Option| { - let mut actors = - StateTree::new(&Arc::new(MemoryDB::default()), StateTreeVersion::V5).unwrap(); - if let Some(code) = code { - let actor = - ActorState::new(code, Cid::default(), TokenAmount::zero().into(), 0, None); - actors.set_actor(&orchestrator, actor).unwrap(); - } - actors - }; - let split = RewardMigrator::new(&bootstrap_params(), 100, Cid::default()).unwrap(); + fn validates_every_actor_the_bootstrap_references() { + let account = Cid::from_cbor_blake2b256(&"account code").unwrap(); + let paych = Cid::from_cbor_blake2b256(&"paych code").unwrap(); + // The SWA, SRA and orchestrator of `bootstrap_params`. + let (swa, sra, orchestrator) = ( + Address::new_id(100), + Address::new_id(101), + Address::new_id(102), + ); + let burn = Some(Address::BURNT_FUNDS_ACTOR); + let system = Some(Address::SYSTEM_ACTOR); + + // On-chain actors: all three as accounts, minus `missing`, with `channel` as a paych. + let on_chain = + |missing: Option
, channel: Option
| -> Vec<(Address, Cid)> { + [swa, sra, orchestrator] + .into_iter() + .filter(|address| Some(*address) != missing) + .map(|address| { + let code = if Some(address) == channel { + paych + } else { + account + }; + (address, code) + }) + .collect() + }; + let split = bootstrap_params(); let neutral = SolsticeRewardBootstrapParams { consensus_weight_ramp_duration_epochs: 0, consensus_weight: NEUTRAL_CONSENSUS_WEIGHT, service_weight: NO_SERVICE_WEIGHT, ..bootstrap_params() }; - let neutral = RewardMigrator::new(&neutral, 100, Cid::default()).unwrap(); - let cases = [ - (&split, Some(account_code), Some(paych_code), Ok(())), - (&neutral, None, None, Ok(())), + for (case, params, actors, paych_code, expected) in [ ( - &split, - None, - Some(paych_code), - Err("reward recipient f0102 does not exist"), + "account references", + split.clone(), + on_chain(None, None), + Some(paych), + Ok(()), + ), + ( + "system actor references", + SolsticeRewardBootstrapParams { + swa_actor: system, + sra_actor: system, + ..split.clone() + }, + vec![(Address::SYSTEM_ACTOR, account), (orchestrator, account)], + Some(paych), + Ok(()), + ), + ( + "neutral bootstrap needs only its SWA", + neutral.clone(), + vec![(swa, account)], + Some(paych), + Ok(()), + ), + ( + "payment channel SWA", + split.clone(), + on_chain(None, Some(swa)), + Some(paych), + Err("SWA actor f0100 is a payment channel"), + ), + ( + "payment channel distribution writer", + split.clone(), + on_chain(None, Some(sra)), + Some(paych), + Err("distribution writer f0101 is a payment channel"), ), ( - &split, - Some(paych_code), - Some(paych_code), + "payment channel recipient", + split.clone(), + on_chain(None, Some(orchestrator)), + Some(paych), Err("reward recipient f0102 is a payment channel"), ), ( - &split, - Some(account_code), + "missing SWA", + split.clone(), + on_chain(Some(swa), None), + Some(paych), + Err("SWA actor f0100 does not exist"), + ), + ( + "missing SWA of a neutral bootstrap", + neutral, + vec![], + Some(paych), + Err("SWA actor f0100 does not exist"), + ), + ( + "missing distribution writer", + split.clone(), + on_chain(Some(sra), None), + Some(paych), + Err("distribution writer f0101 does not exist"), + ), + ( + "missing recipient", + split.clone(), + on_chain(Some(orchestrator), None), + Some(paych), + Err("reward recipient f0102 does not exist"), + ), + ( + "burn SWA", + SolsticeRewardBootstrapParams { + swa_actor: burn, + ..split.clone() + }, + on_chain(None, None), + Some(paych), + Err("SWA actor is the burn actor"), + ), + ( + "burn distribution writer", + SolsticeRewardBootstrapParams { + sra_actor: burn, + ..split.clone() + }, + on_chain(None, None), + Some(paych), + Err("distribution writer is the burn actor"), + ), + ( + "no payment channel code in the old manifest", + split, + on_chain(None, None), None, Err("code cid for payment channel actor not found in old manifest"), ), - ]; - for (migrator, recipient_code, paych_code, expected) in cases { - let result = migrator.validate_recipients(&state_tree_with(recipient_code), paych_code); + ] { + let mut tree = + StateTree::new(&Arc::new(MemoryDB::default()), StateTreeVersion::V5).unwrap(); + for (address, code) in actors { + let actor = + ActorState::new(code, Cid::default(), TokenAmount::zero().into(), 0, None); + tree.set_actor(&address, actor).unwrap(); + } + let migrator = RewardMigrator::new(¶ms, 100, Cid::default()) + .unwrap_or_else(|e| panic!("{case}: {e:#}")); + + let result = migrator.validate_recipients(&tree, paych_code); + match expected { - Ok(()) => result.unwrap(), + Ok(()) => result.unwrap_or_else(|e| panic!("{case}: {e:#}")), Err(message) => { - let error = format!("{:#}", result.unwrap_err()); - assert!(error.contains(message), "{error}"); + let error = result + .err() + .unwrap_or_else(|| panic!("{case}: accepted")) + .to_string(); + assert!(error.contains(message), "{case}: {error}"); } } } diff --git a/src/state_migration/nv29/reward_bootstrap.rs b/src/state_migration/nv29/reward_bootstrap.rs index 2d15ad3a53d0..37f6074e3eb3 100644 --- a/src/state_migration/nv29/reward_bootstrap.rs +++ b/src/state_migration/nv29/reward_bootstrap.rs @@ -3,7 +3,7 @@ //! Per-network reward bootstrap for FIP-0118, the only network-specific input of the Solstice //! migration. Values from the Lotus `params_.go` files at -//! . +//! . use crate::networks::NetworkChain; use crate::rpc::eth::types::EthAddress; @@ -74,11 +74,21 @@ impl SolsticeRewardBootstrapParams { NetworkChain::Mainnet => Self { swa_timelock_epochs: 7 * EPOCHS_IN_DAY, consensus_weight_ramp_duration_epochs: RAMP_QUARTERS * MAINNET_EPOCHS_PER_QUARTER, + swa_actor: Some(evm_address("0xDE4fBd083F18f96C241DdE0A83C3EDC422Be9BA6")), + sra_actor: Some(evm_address("0xeDfCd0947F7E9d58E0035f032520d75ce8eCA451")), + initial_orchestrator: Some(evm_address( + "0x97A90f5696be5E3C8d3752C92Adac287c2b4484e", + )), ..FIP0118 }, NetworkChain::Calibnet => Self { - swa_timelock_epochs: EPOCHS_IN_HOUR, + swa_timelock_epochs: EPOCHS_IN_HOUR * 6, consensus_weight_ramp_duration_epochs: RAMP_QUARTERS * EPOCHS_IN_DAY, + swa_actor: Some(evm_address("0xDE4fBd083F18f96C241DdE0A83C3EDC422Be9BA6")), + sra_actor: Some(evm_address("0xeDfCd0947F7E9d58E0035f032520d75ce8eCA451")), + initial_orchestrator: Some(evm_address( + "0x97A90f5696be5E3C8d3752C92Adac287c2b4484e", + )), ..FIP0118 }, NetworkChain::Butterflynet => Self { @@ -91,12 +101,13 @@ impl SolsticeRewardBootstrapParams { )), ..FIP0118 }, + // A devnet deploys no contracts, so the system actor stands in for all three. NetworkChain::Devnet(_) => Self { swa_timelock_epochs: 50, consensus_weight_ramp_duration_epochs: 900, swa_actor: Some(Address::SYSTEM_ACTOR), sra_actor: Some(Address::SYSTEM_ACTOR), - initial_orchestrator: Some(Address::BURNT_FUNDS_ACTOR), + initial_orchestrator: Some(Address::SYSTEM_ACTOR), ..FIP0118 }, } @@ -106,12 +117,17 @@ impl SolsticeRewardBootstrapParams { /// migration reads. /// /// # Errors - /// The SWA is unset, or a set address is not on chain. + /// The SWA is unset, the orchestrator is the burn actor, or a set address is not on chain. pub fn resolve(mut self, actors: &StateTree) -> anyhow::Result { ensure!( self.swa_actor.is_some(), "Solstice bootstrap SWA actor is unset" ); + // The reward actor strips the burn actor from share maps, so it cannot be a recipient. + ensure!( + self.initial_orchestrator != Some(Address::BURNT_FUNDS_ACTOR), + "Solstice bootstrap initial orchestrator is the burn actor" + ); // A consensus-only bootstrap leaves the service stream addresses unset. for (name, address) in [ ("SWA actor", &mut self.swa_actor), @@ -245,6 +261,23 @@ mod tests { ); } + #[test] + fn rejects_the_burn_actor_as_orchestrator() { + let (swa, sra) = (contract(1), contract(2)); + let (actors, _) = state_tree_with(&[swa, sra]); + + let error = params_with(Some(swa), Some(sra), Some(Address::BURNT_FUNDS_ACTOR)) + .resolve(&actors) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("initial orchestrator is the burn actor"), + "{error:#}" + ); + } + #[test] fn rejects_an_address_missing_from_the_state_tree() { let (swa, sra, orchestrator) = (contract(1), contract(2), wallet(3)); From 15423310cc7d246b8f2efc313a9f5ce36c1e9d34 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Fri, 18 Sep 2026 20:28:14 +0530 Subject: [PATCH 9/9] address comment --- src/rpc/methods/state/tests.rs | 60 ++++++++++++++++++++++++------ src/state_migration/nv29/reward.rs | 59 +++++++++++++---------------- 2 files changed, 73 insertions(+), 46 deletions(-) diff --git a/src/rpc/methods/state/tests.rs b/src/rpc/methods/state/tests.rs index 54294ae6e0a6..ab8ea8ca0f18 100644 --- a/src/rpc/methods/state/tests.rs +++ b/src/rpc/methods/state/tests.rs @@ -444,30 +444,49 @@ async fn initial_pledge_collateral_matches_the_sector_pledge() { ); } -/// From NV29 a pre-commit no longer describes a pledge, so the collateral RPC refuses. -#[tokio::test] -async fn initial_pledge_collateral_is_retired_from_nv29() { - let mut config = ChainConfig::calibnet(); - // A height applies to the epochs after it. +/// Puts nv29 at [`FIXTURE_EPOCH`], because no network schedules Solstice yet. +fn solstice_at_fixture_epoch(mut config: ChainConfig) -> ChainConfig { config .height_infos .get_mut(&Height::Solstice) - .unwrap() - .epoch = FIXTURE_EPOCH - 1; - let (ctx, _) = ctx_at(config, FIXTURE_EPOCH, &Default::default()); + .expect("every network lists Solstice") + .epoch = FIXTURE_EPOCH; + config +} + +async fn initial_pledge_collateral( + config: ChainConfig, + epoch: ChainEpoch, +) -> Result { + let (ctx, _) = ctx_at(config, epoch, &Default::default()); + // Default seal proof is `Invalid`, so that field has to be set; the rest go unread. let pre_commit = SectorPreCommitInfo::from(fil_actor_miner_state::v18::SectorPreCommitInfo { seal_proof: RegisteredSealProofV4::StackedDRG32GiBV1P1, - expiration: FIXTURE_EPOCH + 1_000, + expiration: epoch + 1_000, ..Default::default() }); - - let error = StateMinerInitialPledgeCollateral::handle( + StateMinerInitialPledgeCollateral::handle( ctx, (Address::new_id(1000), pre_commit, ApiTipsetKey(None)), &Default::default(), ) .await - .unwrap_err(); +} + +/// From NV29 a pre-commit no longer describes a pledge, so the collateral RPC refuses. +#[rstest] +#[case::mainnet(ChainConfig::mainnet())] +#[case::calibnet(ChainConfig::calibnet())] +#[case::butterflynet(ChainConfig::butterflynet())] +#[case::devnet(ChainConfig::devnet())] +#[tokio::test] +async fn initial_pledge_collateral_is_retired_from_nv29(#[case] config: ChainConfig) { + let config = solstice_at_fixture_epoch(config); + let activation = first_epoch_of(&config, Height::Solstice); + + let error = initial_pledge_collateral(config, activation) + .await + .unwrap_err(); assert!( error @@ -477,6 +496,23 @@ async fn initial_pledge_collateral_is_retired_from_nv29() { ); } +#[rstest] +#[case::mainnet(ChainConfig::mainnet())] +#[case::calibnet(ChainConfig::calibnet())] +#[case::butterflynet(ChainConfig::butterflynet())] +#[case::devnet(ChainConfig::devnet())] +#[tokio::test] +async fn initial_pledge_collateral_answers_until_nv29(#[case] config: ChainConfig) { + let config = solstice_at_fixture_epoch(config); + let activation = first_epoch_of(&config, Height::Solstice); + + let pledge = initial_pledge_collateral(config, activation - 1) + .await + .unwrap(); + + assert!(pledge.is_positive()); +} + #[rstest] #[case::no_power_actor(Address::POWER_ACTOR)] #[case::no_reward_actor(Address::REWARD_ACTOR)] diff --git a/src/state_migration/nv29/reward.rs b/src/state_migration/nv29/reward.rs index be9fc85b7bd2..d7f35315d5fe 100644 --- a/src/state_migration/nv29/reward.rs +++ b/src/state_migration/nv29/reward.rs @@ -355,10 +355,11 @@ impl ActorMigration for RewardMigrator { mod tests { use super::*; use crate::db::MemoryDB; - use crate::networks::{ChainConfig, Height, UPGRADE_HEIGHT_UNSCHEDULED}; + use crate::networks::{ChainConfig, Height}; use crate::shim::state_tree::{ActorState, StateTreeVersion}; use crate::utils::cid::CidCborExt as _; use fil_actors_shared::v18::builtin::reward::smooth::FilterEstimate as FilterEstimateOld; + use rstest::rstest; use std::sync::Arc; use super::super::reward_bootstrap::PERCENT; @@ -650,39 +651,29 @@ mod tests { } } - #[test] - fn scheduled_networks_have_complete_bootstrap_addresses() { - for config in [ - ChainConfig::mainnet(), - ChainConfig::calibnet(), - ChainConfig::butterflynet(), - ] { - let solstice_epoch = config.epoch(Height::Solstice); - if solstice_epoch == UPGRADE_HEIGHT_UNSCHEDULED { - continue; - } - let params = SolsticeRewardBootstrapParams::for_chain(&config.network); - assert!( - params.swa_actor.is_some() - && params.sra_actor.is_some() - && params.initial_orchestrator.is_some(), - "{}: scheduled without all bootstrap addresses", - config.network - ); - // The migration resolves the addresses on chain; stand-ins leave the weights to check. - let params = SolsticeRewardBootstrapParams { - swa_actor: Some(Address::new_id(100)), - sra_actor: Some(Address::new_id(101)), - initial_orchestrator: Some(Address::new_id(102)), - ..params - }; - RewardMigrator::new(¶ms, solstice_epoch + 1, Cid::default()).unwrap_or_else(|e| { - panic!( - "{}: scheduled without a valid bootstrap: {e:#}", - config.network - ) - }); - } + #[rstest] + #[case::mainnet(ChainConfig::mainnet())] + #[case::calibnet(ChainConfig::calibnet())] + #[case::butterflynet(ChainConfig::butterflynet())] + #[case::devnet(ChainConfig::devnet())] + fn every_network_has_a_complete_and_valid_bootstrap(#[case] config: ChainConfig) { + let params = SolsticeRewardBootstrapParams::for_chain(&config.network); + assert!( + params.swa_actor.is_some() + && params.sra_actor.is_some() + && params.initial_orchestrator.is_some(), + "bootstrap addresses are incomplete" + ); + // The migration resolves the addresses on chain; stand-ins leave the weights to check. + let params = SolsticeRewardBootstrapParams { + swa_actor: Some(Address::new_id(100)), + sra_actor: Some(Address::new_id(101)), + initial_orchestrator: Some(Address::new_id(102)), + ..params + }; + let activation_epoch = config.epoch(Height::Solstice) + 1; + + RewardMigrator::new(¶ms, activation_epoch, Cid::default()).unwrap(); } #[test]