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/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 a30db0b1e429..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 value is far too low; 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,6 +1120,13 @@ 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() diff --git a/src/rpc/methods/state/tests.rs b/src/rpc/methods/state/tests.rs index 0ee597532dc4..ab8ea8ca0f18 100644 --- a/src/rpc/methods/state/tests.rs +++ b/src/rpc/methods/state/tests.rs @@ -444,6 +444,75 @@ async fn initial_pledge_collateral_matches_the_sector_pledge() { ); } +/// 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) + .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: epoch + 1_000, + ..Default::default() + }); + StateMinerInitialPledgeCollateral::handle( + ctx, + (Address::new_id(1000), pre_commit, ApiTipsetKey(None)), + &Default::default(), + ) + .await +} + +/// 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 + .to_string() + .contains("unsupported from network version 29"), + "{error}" + ); +} + +#[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/rpc/snapshots/forest__rpc__tests__rpc__v0.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap index 3f62dbf7c128..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 value is far too low; 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 ea970d96aaaa..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 value is far too low; 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/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; 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..972e41c2dc5b --- /dev/null +++ b/src/state_migration/nv29/market.rs @@ -0,0 +1,95 @@ +// 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:?}")); + 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 new file mode 100644 index 000000000000..17bdc60ef3e8 --- /dev/null +++ b/src/state_migration/nv29/migration.rs @@ -0,0 +1,112 @@ +// 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::reward_bootstrap::SolsticeRewardBootstrapParams; +use super::{SystemStateOld, system, verifier::Verifier}; +use crate::networks::{ChainConfig, Height}; +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, + 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)?; + 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), + ); + // 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( + &bootstrap, + 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(reward_migrator), + ); + 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)); + 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)?; + 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..f6c2d0c9231e --- /dev/null +++ b/src/state_migration/nv29/mod.rs @@ -0,0 +1,23 @@ +// 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; +mod reward_bootstrap; + +/// 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..d7f35315d5fe --- /dev/null +++ b/src/state_migration/nv29/reward.rs @@ -0,0 +1,843 @@ +// 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). +//! +//! 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}; +use cid::Cid; +use fil_actor_reward_state::v18::State as RewardStateOld; +use fil_actor_reward_state::v19::{ + 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; +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 for a network without service contracts. +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 and validates the bootstrap streams starting at `activation_epoch`, the first + /// epoch executed on the migrated state. + /// + /// # Errors + /// 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) = 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!( + 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, + }) + } + + /// Checks the SWA, every distribution writer and every share recipient against `actors`. + /// + /// # Errors + /// `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; + }; + validate_actor_reference( + actors, + distribution.writer, + "distribution writer", + paych_code, + )?; + for share in &distribution.shares { + 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( + params: &SolsticeRewardBootstrapParams, + activation_epoch: ChainEpoch, +) -> anyhow::Result> { + let record = |weight: SolsticeRewardWeightParams, slope: i64| WeightRecord { + v_start: weight.v_start, + slope, + t_start: activation_epoch, + floor: weight.floor, + cap: weight.cap, + }; + + 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" + ); + return Ok(vec![RegisterStreamParams { + id: CONSENSUS_STREAM_ID, + weight: record(params.consensus_weight, 0), + distribution: None, + 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!( + 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" + ); + 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)) +} + +/// 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}")) +} + +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}; + 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; + + 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, + // 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)), + 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); + } + + #[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_queue: vec![], + } + ); + assert!(migrator.accrued.is_empty()); + } + + #[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:#}" + ); + } + } + + #[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:#}" + ); + } + } + + #[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] + 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() + }; + + for (case, params, actors, paych_code, expected) in [ + ( + "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"), + ), + ( + "payment channel recipient", + split.clone(), + on_chain(None, Some(orchestrator)), + Some(paych), + Err("reward recipient f0102 is a payment channel"), + ), + ( + "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"), + ), + ] { + 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_or_else(|e| panic!("{case}: {e:#}")), + Err(message) => { + 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 new file mode 100644 index 000000000000..37f6074e3eb3 --- /dev/null +++ b/src/state_migration/nv29/reward_bootstrap.rs @@ -0,0 +1,300 @@ +// 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::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)] +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. + 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 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, + 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 => 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 * 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 { + 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 + }, + // 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::SYSTEM_ACTOR), + ..FIP0118 + }, + } + } + + /// Resolves the contract addresses to `f0` addresses against `actors`, the state tree the + /// migration reads. + /// + /// # Errors + /// 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), + ("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_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)); + 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:#}"); + } + } +} 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, + }) + } +}