use super::*;
use crate::blocks::{CachingBlockHeader, Chain4U, HeaderBuilder, RawBlockHeader};
use crate::chain::ChainStore;
use crate::networks::{ACTOR_BUNDLES_METADATA, ActorBundleMetadata, Height, NetworkChain};
use crate::rpc::test_utils::chain_store_with_config;
use crate::rpc::{DbImpl, RPCState};
use crate::shim::actors::reward::RewardDistributionError;
use crate::shim::machine::BuiltinActor;
use crate::shim::sector::RegisteredSealProofV4;
use crate::shim::state_tree::{ActorState, StateTree, StateTreeVersion};
use crate::state_manager::circulating_supply::GenesisInfo;
use crate::utils::db::CborStoreExt as _;
use fil_actors_shared::v18::builtin::reward::smooth::FilterEstimate;
use num_traits::Zero as _;
use quickcheck_macros::quickcheck;
use rstest::rstest;
#[rstest]
#[case(1_000, Some(600))]
#[case(401, Some(1))]
#[case(400, None)]
#[case(399, None)]
#[case(i64::MIN, None)]
fn sector_duration_from_expiration_requires_positive(
#[case] expiration: ChainEpoch,
#[case] expected: Option<ChainEpoch>,
) {
assert_eq!(
sector_duration_from_expiration(expiration, 400).ok(),
expected
);
}
#[quickcheck]
fn sector_duration_from_expiration_no_panic(expiration: ChainEpoch, epoch: ChainEpoch) {
let _ = sector_duration_from_expiration(expiration, epoch);
}
const FIXTURE_EPOCH: ChainEpoch = 10_000;
fn qa_sector_power() -> StoragePower {
StoragePower::from(1u64 << 36)
}
fn fixture_bundle() -> &'static ActorBundleMetadata {
ACTOR_BUNDLES_METADATA
.values()
.find(|bundle| {
bundle.network == NetworkChain::Calibnet
&& bundle.actor_major_version().ok() == Some(18)
})
.expect("v18 actor bundle is embedded")
}
struct PledgeInputs {
baseline_power: StoragePower,
reward_smoothed: FilterEstimate,
network_qa_power_smoothed: FilterEstimate,
ramp_start_epoch: ChainEpoch,
ramp_duration_epochs: u64,
}
impl Default for PledgeInputs {
fn default() -> Self {
Self {
baseline_power: StoragePower::from(1u64 << 37),
reward_smoothed: FilterEstimate::new(0.into(), 0.into()),
network_qa_power_smoothed: FilterEstimate::new(
(1u64 << 10).into(),
(1u64 << 10).into(),
),
ramp_start_epoch: 0,
ramp_duration_epochs: 0,
}
}
}
impl PledgeInputs {
fn write_state_tree(
&self,
chain_store: &ChainStore,
epoch: ChainEpoch,
omit: Option<Address>,
) -> Cid {
let store = chain_store.db();
let config = chain_store.chain_config();
let manifest = &fixture_bundle().manifest;
let power_state = power::State::V18(fil_actor_power_state::v18::State {
this_epoch_qa_power_smoothed: self.network_qa_power_smoothed.clone(),
ramp_start_epoch: self.ramp_start_epoch,
ramp_duration_epochs: self.ramp_duration_epochs,
..Default::default()
});
let reward_cid = |mined: &TokenAmount| {
let state = reward::State::V18(fil_actor_reward_state::v18::State {
this_epoch_reward_smoothed: self.reward_smoothed.clone(),
this_epoch_baseline_power: self.baseline_power.clone(),
epoch,
total_storage_power_reward: mined.into(),
..Default::default()
});
store.put_cbor_default(&state).unwrap()
};
let market_state = market::State::V18(
fil_actor_market_state::v18::State::new(store).expect("empty market state"),
);
let put = |tree: &mut StateTree<DbImpl>, address: &Address, code, state, balance| {
if omit.as_ref() != Some(address) {
tree.set_actor(address, ActorState::new(code, state, balance, 0, None))
.unwrap()
}
};
let builtin = |tree: &mut _, actor, address: &Address, state| {
put(
tree,
address,
manifest.get(actor).unwrap(),
state,
TokenAmount::zero(),
)
};
let funded = |tree: &mut _, address: &Address, balance| {
put(tree, address, Cid::default(), Cid::default(), balance)
};
let mut tree = StateTree::new(store, StateTreeVersion::V5).unwrap();
let power_cid = store.put_cbor_default(&power_state).unwrap();
let market_cid = store.put_cbor_default(&market_state).unwrap();
builtin(
&mut tree,
BuiltinActor::Power,
&Address::POWER_ACTOR,
power_cid,
);
builtin(
&mut tree,
BuiltinActor::Market,
&Address::MARKET_ACTOR,
market_cid,
);
builtin(
&mut tree,
BuiltinActor::Reward,
&Address::REWARD_ACTOR,
reward_cid(&TokenAmount::zero()),
);
funded(
&mut tree,
&Address::RESERVE_ACTOR,
config.initial_fil_reserved_at_height(epoch).clone(),
);
funded(&mut tree, &Address::BURNT_FUNDS_ACTOR, TokenAmount::zero());
if omit.is_some() {
return tree.flush().unwrap();
}
let genesis_info = GenesisInfo::from_chain_config(config.clone());
let supply = |tree: &StateTree<DbImpl>| {
genesis_info
.get_vm_circulating_supply_detailed_with_state_tree(epoch, tree)
.unwrap()
.fil_circulating
};
let target = TokenAmount::from_whole(1);
let gap = supply(&tree) - ⌖
let excess = gap.clone().max(TokenAmount::zero());
let shortfall = (-gap).max(TokenAmount::zero());
funded(&mut tree, &Address::BURNT_FUNDS_ACTOR, excess);
builtin(
&mut tree,
BuiltinActor::Reward,
&Address::REWARD_ACTOR,
reward_cid(&shortfall),
);
assert_eq!(
supply(&tree),
target,
"fixture failed to reach the requested circulating supply"
);
tree.flush().unwrap()
}
}
fn build_ctx(
config: ChainConfig,
epoch: ChainEpoch,
inputs: &PledgeInputs,
omit: Option<Address>,
) -> (Ctx, Tipset) {
let chain_store = chain_store_with_config(config);
let state_root = inputs.write_state_tree(&chain_store, epoch, omit);
let blocks = Chain4U::with_blockstore(chain_store.db_owned());
let mut header = HeaderBuilder::new();
header.with_epoch(epoch).with_state_root(state_root);
blocks.insert(&[], "head", header);
let head = blocks.tipset(&["head"]);
chain_store.set_heaviest_tipset(head.clone()).unwrap();
let (ctx, _network_rx) = RPCState::for_tests(chain_store).unwrap();
(ctx, head)
}
fn ctx_at(config: ChainConfig, epoch: ChainEpoch, inputs: &PledgeInputs) -> (Ctx, Tipset) {
build_ctx(config, epoch, inputs, None)
}
fn pledge_for(inputs: &PledgeInputs) -> Result<TokenAmount, ServerError> {
let (ctx, ts) = ctx_at(ChainConfig::calibnet(), FIXTURE_EPOCH, inputs);
compute_initial_pledge_for_power(&ctx, &ts, &qa_sector_power())
}
fn first_epoch_of(config: &ChainConfig, height: Height) -> ChainEpoch {
config.epoch(height) + 1
}
fn goldenweek_at_fixture_epoch() -> ChainConfig {
let mut config = ChainConfig::calibnet();
config
.height_infos
.get_mut(&Height::GoldenWeek)
.expect("calibnet schedules GoldenWeek")
.epoch = FIXTURE_EPOCH;
config
.height_infos
.sort_by(|_, a, _, b| a.epoch.cmp(&b.epoch));
config
}
async fn creation_deposit(config: ChainConfig, epoch: ChainEpoch) -> TokenAmount {
let (ctx, _) = ctx_at(config, epoch, &Default::default());
StateMinerCreationDeposit::handle(ctx, (ApiTipsetKey(None),), &Default::default())
.await
.unwrap()
}
#[rstest]
#[case::before_ramp_start(-100, 100, TokenAmount::from_micro(150_000))]
#[case::ramp_edge_before(-1, 10, TokenAmount::from_micro(150_000))]
#[case::no_ramp(0, 0, TokenAmount::from_micro(195_000))]
#[case::no_ramp_after_start(10, 0, TokenAmount::from_micro(195_000))]
#[case::at_ramp_start(0, 100, TokenAmount::from_micro(150_000))]
#[case::ramp_edge_at_start(0, 10, TokenAmount::from_micro(150_000))]
#[case::ramp_first_step(1, 10, TokenAmount::from_micro(154_500))]
#[case::ramp_early(10, 100, TokenAmount::from_micro(154_500))]
#[case::ramp_mid(50, 100, TokenAmount::from_micro(172_500))]
#[case::after_ramp(150, 100, TokenAmount::from_micro(195_000))]
#[case::long_after_ramp(500, 100, TokenAmount::from_micro(195_000))]
#[case::dormant_ramp(FIXTURE_EPOCH, 100_000, TokenAmount::from_micro(195_000))]
#[tokio::test]
async fn initial_pledge_matches_upstream_fip0081_vectors(
#[case] epochs_since_ramp_start: ChainEpoch,
#[case] ramp_duration_epochs: u64,
#[case] expected_share: TokenAmount,
) {
let inputs = PledgeInputs {
ramp_start_epoch: FIXTURE_EPOCH - epochs_since_ramp_start,
ramp_duration_epochs,
..Default::default()
};
assert_eq!(
pledge_for(&inputs).unwrap(),
TokenAmount::from_atto(1) + expected_share
);
}
#[tokio::test]
async fn network_power_estimate_is_wired_through() {
let inputs = PledgeInputs {
network_qa_power_smoothed: FilterEstimate::new((1u64 << 40).into(), 0.into()),
..Default::default()
};
assert_eq!(
pledge_for(&inputs).unwrap(),
TokenAmount::from_atto(1) + TokenAmount::from_micro(18_750)
);
}
#[tokio::test]
async fn reward_estimate_is_wired_through() {
let with_reward = |reward: u64| {
pledge_for(&PledgeInputs {
reward_smoothed: FilterEstimate::new(reward.into(), 0.into()),
..Default::default()
})
.unwrap()
};
assert!(with_reward(1_000) > with_reward(0));
assert!(with_reward(2_000) > with_reward(1_000));
}
#[rstest]
#[case::mainnet(ChainConfig::mainnet())]
#[case::calibnet(ChainConfig::calibnet())]
#[case::synthetic(goldenweek_at_fixture_epoch())]
#[tokio::test]
async fn creation_deposit_is_positive_from_goldenweek(#[case] config: ChainConfig) {
let activation = first_epoch_of(&config, Height::GoldenWeek);
assert!(creation_deposit(config, activation).await.is_positive());
}
#[rstest]
#[case::mainnet(ChainConfig::mainnet())]
#[case::calibnet(ChainConfig::calibnet())]
#[case::synthetic(goldenweek_at_fixture_epoch())]
#[tokio::test]
async fn creation_deposit_is_zero_before_goldenweek(#[case] config: ChainConfig) {
let activation = first_epoch_of(&config, Height::GoldenWeek);
assert!(creation_deposit(config, activation - 1).await.is_zero());
}
#[rstest]
#[case::mainnet(ChainConfig::mainnet())]
#[case::calibnet(ChainConfig::calibnet())]
#[tokio::test]
async fn creation_deposit_is_the_pledge_for_a_tenth_of_minimum_consensus_power(
#[case] config: ChainConfig,
) {
let epoch = first_epoch_of(&config, Height::GoldenWeek);
let inputs = PledgeInputs {
baseline_power: StoragePower::from(1u128 << 70),
..Default::default()
};
let (ctx, ts) = ctx_at(config, epoch, &inputs);
let minimum = ctx.chain_config().policy.minimum_consensus_power.clone();
let deposit =
StateMinerCreationDeposit::handle(ctx.clone(), (ApiTipsetKey(None),), &Default::default())
.await
.unwrap();
assert_eq!(
deposit,
compute_initial_pledge_for_power(&ctx, &ts, &(&minimum / 10)).unwrap()
);
assert_ne!(
deposit,
compute_initial_pledge_for_power(&ctx, &ts, &minimum).unwrap(),
"fixture no longer discriminates between powers"
);
}
#[tokio::test]
async fn initial_pledge_for_sector_adds_the_buffer() {
let (ctx, ts) = ctx_at(ChainConfig::calibnet(), FIXTURE_EPOCH, &Default::default());
let duration = 1_000;
let sector_size = SectorSize::_32GiB;
let pledge = StateMinerInitialPledgeForSector::handle(
ctx.clone(),
(duration, sector_size, 0, ApiTipsetKey(None)),
&Default::default(),
)
.await
.unwrap();
let sector_weight = qa_power_for_weight(
sector_size.into(),
duration,
&BigInt::from(0),
&BigInt::from(0),
);
let unbuffered = compute_initial_pledge_for_power(&ctx, &ts, §or_weight).unwrap();
assert_eq!(pledge, (unbuffered * 110u64).div_floor(100u64));
}
#[tokio::test]
async fn initial_pledge_collateral_matches_the_sector_pledge() {
let (ctx, _) = ctx_at(ChainConfig::calibnet(), FIXTURE_EPOCH, &Default::default());
let duration = 1_000;
let pre_commit = SectorPreCommitInfo::from(fil_actor_miner_state::v18::SectorPreCommitInfo {
seal_proof: RegisteredSealProofV4::StackedDRG32GiBV1P1,
expiration: FIXTURE_EPOCH + duration,
..Default::default()
});
assert_eq!(
StateMinerInitialPledgeCollateral::handle(
ctx.clone(),
(Address::new_id(1000), pre_commit, ApiTipsetKey(None)),
&Default::default(),
)
.await
.unwrap(),
StateMinerInitialPledgeForSector::handle(
ctx,
(duration, SectorSize::_32GiB, 0, ApiTipsetKey(None)),
&Default::default(),
)
.await
.unwrap()
);
}
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
.height_infos
.sort_by(|_, a, _, b| a.epoch.cmp(&b.epoch));
config
}
async fn initial_pledge_collateral(
config: ChainConfig,
epoch: ChainEpoch,
) -> Result<TokenAmount, ServerError> {
let (ctx, _) = ctx_at(config, epoch, &Default::default());
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
}
#[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)]
#[case::no_market_actor(Address::MARKET_ACTOR)]
#[case::no_reserve_actor(Address::RESERVE_ACTOR)]
#[case::no_burnt_funds_actor(Address::BURNT_FUNDS_ACTOR)]
#[tokio::test]
async fn initial_pledge_fails_without(#[case] missing: Address) {
let (ctx, ts) = build_ctx(
ChainConfig::calibnet(),
FIXTURE_EPOCH,
&Default::default(),
Some(missing),
);
assert!(compute_initial_pledge_for_power(&ctx, &ts, &qa_sector_power()).is_err());
}
#[tokio::test]
async fn initial_pledge_fails_on_an_unknown_state_root() {
let (ctx, _) = ctx_at(ChainConfig::calibnet(), FIXTURE_EPOCH, &Default::default());
let unknown = Tipset::from(CachingBlockHeader::new(RawBlockHeader {
state_root: Cid::default(),
epoch: FIXTURE_EPOCH,
..Default::default()
}));
assert!(compute_initial_pledge_for_power(&ctx, &unknown, &qa_sector_power()).is_err());
}
#[test]
fn reward_distribution_json_keeps_empty_lists_and_negative_rounding() {
use serde_json::{from_value, json};
let amounts = json!({
"MintedReward": "0", "MinerReward": "0", "MessageReward": "0", "ExplicitReward": "0",
"BurnAllocation": "0", "MinerPaid": "0", "BurnPaid": "0",
});
let no_blocks = json!({
"TipSetKey": [{"/": "baeaaaaa"}], "Height": 101, "Denom": "1000000000000000000",
"Totals": amounts, "Blocks": [],
});
let no_streams = json!({
"Block": {"/": "baeaaaaa"}, "Miner": "f01000", "WinCount": 1, "Amounts": amounts,
"BurnWeight": "1000000000000000000", "Streams": [],
});
let no_recipients = json!({
"Writer": "f01001", "Recipients": [], "BurnShare": "1000000000000000000",
"BurnAmount": "10", "RoundingAdjustment": "-1",
});
let distribution: RewardDistribution = from_value(no_blocks.clone()).unwrap();
let block: BlockReward = from_value(no_streams.clone()).unwrap();
let explicit: ExplicitRewardDistribution = from_value(no_recipients.clone()).unwrap();
assert_eq!(json!(distribution), no_blocks);
assert_eq!(json!(block), no_streams);
assert_eq!(json!(explicit), no_recipients);
assert_eq!(explicit.rounding_adjustment, TokenAmount::from_atto(-1));
}
#[tokio::test]
async fn reward_distribution_is_refused_at_the_solstice_epoch() {
let config = ChainConfig::calibnet();
let (ctx, head) = ctx_at(
config.clone(),
config.epoch(Height::Solstice),
&Default::default(),
);
let error = ctx
.state_manager
.reward_distribution(&head)
.await
.unwrap_err();
assert!(matches!(
error.downcast_ref(),
Some(RewardDistributionError::RewardActorBeforeV19)
));
}
#[tokio::test]
async fn reward_distribution_is_not_refused_after_the_solstice_epoch() {
let config = ChainConfig::calibnet();
let epoch = first_epoch_of(&config, Height::Solstice);
let (ctx, head) = ctx_at(config, epoch, &Default::default());
let error = ctx
.state_manager
.reward_distribution(&head)
.await
.unwrap_err();
assert!(error.downcast_ref::<RewardDistributionError>().is_none());
}
#[tokio::test]
async fn reward_distribution_at_epoch_0_has_no_blocks() {
let mut config = ChainConfig::calibnet();
config.genesis_network = NetworkVersion::V29;
let (ctx, genesis) = ctx_at(config, 0, &Default::default());
let selector = TipsetSelector {
key: genesis.key().into(),
..Default::default()
};
let distribution = StateRewardDistribution::handle(ctx, (selector,), &Default::default())
.await
.unwrap();
assert_eq!(distribution.height, 0);
assert!(distribution.blocks.is_empty());
assert_eq!(distribution.totals, RewardAmounts::default());
}