From e2282c6f935009c0c0381d3609f3dfd5c6223f30 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Fri, 7 Aug 2026 12:38:47 +0530 Subject: [PATCH 1/4] feat(miner): add ExtendSectorExpiration3 param types --- actors/miner/src/types.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/actors/miner/src/types.rs b/actors/miner/src/types.rs index cd46b5089..7064d195e 100644 --- a/actors/miner/src/types.rs +++ b/actors/miner/src/types.rs @@ -259,6 +259,23 @@ pub struct ExpirationExtension2 { pub new_expiration: ChainEpoch, } +#[derive(Clone, Debug, Serialize_tuple, Deserialize_tuple)] +pub struct ExtendSectorExpiration3Params { + pub extensions: Vec, +} + +#[derive(Clone, Debug, Serialize_tuple, Deserialize_tuple)] +pub struct ExpirationExtension3 { + pub deadline: u64, + pub partition: u64, + /// Sectors to upgrade to full quality-adjusted power (FIP-0118). + pub sectors: BitField, + /// Unset means upgrade only: every selected sector keeps its own expiration. + /// Otherwise the absolute epoch to extend all selected sectors to; it must be + /// after the current epoch and at or beyond each sector's current expiration. + pub new_expiration: Option, +} + #[derive(Serialize_tuple, Deserialize_tuple)] pub struct TerminateSectorsParams { pub terminations: Vec, From 94cb24925de4979174c47a8e26607fb238c202c9 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Fri, 7 Aug 2026 12:38:47 +0530 Subject: [PATCH 2/4] feat(miner): add ExtendSectorExpiration3 to upgrade sectors to 10x QAP --- actors/miner/src/lib.rs | 420 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 416 insertions(+), 4 deletions(-) diff --git a/actors/miner/src/lib.rs b/actors/miner/src/lib.rs index 8cdf7bd91..89acbecff 100644 --- a/actors/miner/src/lib.rs +++ b/actors/miner/src/lib.rs @@ -141,6 +141,7 @@ pub enum Method { ProveCommitSectors3 = 34, ProveReplicaUpdates3 = 35, ProveCommitSectorsNI = 36, + ExtendSectorExpiration3 = 37, // Method numbers derived from FRC-0042 standards ChangeWorkerAddressExported = frc42_dispatch::method_hash!("ChangeWorkerAddress"), ChangePeerIDExported = frc42_dispatch::method_hash!("ChangePeerID"), @@ -2330,16 +2331,289 @@ impl Actor { Ok((power_delta, pledge_delta)) })?; - // power_delta should be zero in most cases, but can be negative if claims are dropped in - // the process of extending sector expirations. + // Power delta is zero when weights are unchanged; extending a legacy + // (non-simple QA power) sector decays its weights, which makes it negative. request_update_power(rt, power_delta)?; - // Note: the pledge delta is expected to be zero, since pledge is not re-calculated for the extension. - // But in case that ever changes, we can do the right thing here. + // Pledge is not recalculated on plain extension, so this is expected to be zero. notify_pledge_changed(rt, &pledge_delta)?; Ok(()) } + /// Upgrades sectors to full quality-adjusted power (FIP-0118), optionally also + /// extending their expiration, and locks the pledge top-up the new power requires. + /// + /// May only be called by the miner's owner, worker, or a control address. Each + /// declaration addresses active sectors in one partition; a declaration without a + /// new expiration upgrades its sectors in place, leaving their expirations, power + /// base epochs and weights untouched. + /// + /// # Errors + /// Aborts with `USR_INSUFFICIENT_FUNDS` unless the available balance covers the + /// whole batch's pledge increase and any outstanding fee debt, which is repaid in + /// the same call. Any invalid declaration or non-active sector fails the whole + /// message; no partial upgrade is applied. + fn extend_sector_expiration3( + rt: &impl Runtime, + params: ExtendSectorExpiration3Params, + ) -> Result<(), ActorError> { + validate_extension_declarations3(rt, ¶ms.extensions)?; + + // Pledge inputs come from other actors and must be fetched before the + // transaction, where sends are blocked. + let rew = request_current_epoch_block_reward(rt)?; + let pow = request_current_total_power(rt)?; + let pledge_inputs = NetworkPledgeInputs { + network_qap: pow.quality_adj_power_smoothed, + network_baseline: rew.this_epoch_baseline_power, + circulating_supply: rt.total_fil_circ_supply(), + epoch_reward: rew.this_epoch_reward_smoothed, + epochs_since_ramp_start: rt.curr_epoch() - pow.ramp_start_epoch, + ramp_duration_epochs: pow.ramp_duration_epochs, + }; + let curr_epoch = rt.curr_epoch(); + + let (power_delta, pledge_delta, fee_to_burn) = rt.transaction(|state: &mut State, rt| { + let info = get_miner_info(rt.store(), state)?; + rt.validate_immediate_caller_is( + info.control_addresses.iter().chain(&[info.worker, info.owner]), + )?; + + // The upgraded power and its pledge depend only on the sector size, so + // they are the same for every sector in the batch. + let full_qa_power = qa_power_max(info.sector_size); + let full_power_pledge = pledge_inputs.initial_pledge_for_power(&full_qa_power); + + let mut deadlines = + state.load_deadlines(rt.store()).map_err(|e| e.wrap("failed to load deadlines"))?; + + // Group declarations by deadline, and remember iteration order. + let mut decls_by_deadline: Vec<_> = std::iter::repeat_with(Vec::new) + .take(rt.policy().wpost_period_deadlines as usize) + .collect(); + let mut deadlines_to_load = Vec::::new(); + for decl in ¶ms.extensions { + // the deadline indices are already checked. + let decls = &mut decls_by_deadline[decl.deadline as usize]; + if decls.is_empty() { + deadlines_to_load.push(decl.deadline); + } + decls.push(decl); + } + + let mut sectors = Sectors::load(rt.store(), &state.sectors).map_err(|e| { + e.downcast_default(ExitCode::USR_ILLEGAL_STATE, "failed to load sectors array") + })?; + + let mut power_delta = PowerPair::zero(); + let mut pledge_delta = TokenAmount::zero(); + + for deadline_idx in deadlines_to_load { + let policy = rt.policy(); + let mut deadline = deadlines.load_deadline(rt.store(), deadline_idx)?; + + let mut partitions = deadline.partitions_amt(rt.store()).map_err(|e| { + e.downcast_default( + ExitCode::USR_ILLEGAL_STATE, + format!("failed to load partitions for deadline {}", deadline_idx), + ) + })?; + + let quant = state.quant_spec_for_deadline(policy, deadline_idx); + + let mut deadline_power_delta = PowerPair::zero(); + let mut deadline_pledge_delta = TokenAmount::zero(); + let mut deadline_daily_fee_delta = TokenAmount::zero(); + + // Partitions grouped by the epoch they extend to, for the queue + // reschedule below. Upgrade-only declarations never enter this map: + // their sectors keep their already scheduled expirations. + let mut partitions_by_new_epoch = BTreeMap::>::new(); + let mut epochs_to_reschedule = Vec::::new(); + + for decl in &decls_by_deadline[deadline_idx as usize] { + let key = PartitionKey { deadline: deadline_idx, partition: decl.partition }; + + let mut partition = partitions + .get(decl.partition) + .map_err(|e| { + e.downcast_default( + ExitCode::USR_ILLEGAL_STATE, + format!("failed to load partition {:?}", key), + ) + })? + .cloned() + .ok_or_else(|| actor_error!(not_found, "no such partition {:?}", key))?; + + // Only active sectors may upgrade; a faulty, unproven, terminated + // or foreign sector fails the whole message. + if !partition.active_sectors().contains_all(&decl.sectors) { + return Err(actor_error!( + illegal_argument, + "can only upgrade active sectors in partition {:?}", + key + )); + } + + let old_sectors = sectors + .load_sectors(&decl.sectors) + .map_err(|e| e.wrap("failed to load sectors"))?; + let new_sectors: Vec = old_sectors + .iter() + .map(|sector| { + upgrade_sector_to_full_power( + rt.policy(), + curr_epoch, + decl.new_expiration, + sector, + info.sector_size, + &pledge_inputs.circulating_supply, + &full_qa_power, + &full_power_pledge, + ) + }) + .collect::>()?; + + // Swap the records in the partition's expiration queue, collecting + // the power, pledge and fee differences. + let (partition_power_delta, partition_pledge_delta, partition_daily_fee_delta) = + partition + .replace_sectors( + rt.store(), + &old_sectors, + &new_sectors, + info.sector_size, + quant, + ) + .map_err(|e| { + e.downcast_default( + ExitCode::USR_ILLEGAL_STATE, + format!("failed to replace sector expirations at {:?}", key), + ) + })?; + + deadline_power_delta += &partition_power_delta; + deadline_pledge_delta += &partition_pledge_delta; + deadline_daily_fee_delta += &partition_daily_fee_delta; + + // Overwrite sector infos. Later declarations read these records + // back, so a duplicated sector is upgraded once and then no-ops. + sectors.store(new_sectors).map_err(|e| { + e.downcast_default( + ExitCode::USR_ILLEGAL_STATE, + format!("failed to update sectors {:?}", decl.sectors), + ) + })?; + + partitions.set(decl.partition, partition).map_err(|e| { + e.downcast_default( + ExitCode::USR_ILLEGAL_STATE, + format!("failed to save partition {:?}", key), + ) + })?; + + // Extensions move sectors to a new expiration epoch, so their + // partitions must be re-registered in the deadline's queue. + if let Some(new_expiration) = decl.new_expiration { + let prev_epoch_partitions = partitions_by_new_epoch.entry(new_expiration); + let not_exists = matches!(prev_epoch_partitions, Entry::Vacant(_)); + + prev_epoch_partitions.or_default().push(decl.partition); + if not_exists { + // reschedule epoch if the partition for new epoch didn't already exist + epochs_to_reschedule.push(new_expiration); + } + } + } + + deadline.live_power += &deadline_power_delta; + deadline.daily_fee += &deadline_daily_fee_delta; + + power_delta += &deadline_power_delta; + pledge_delta += &deadline_pledge_delta; + + deadline.partitions = partitions.flush().map_err(|e| { + e.downcast_default( + ExitCode::USR_ILLEGAL_STATE, + format!("failed to save partitions for deadline {}", deadline_idx), + ) + })?; + + // Record partitions in deadline expiration queue + for epoch in epochs_to_reschedule { + let p_idxs = partitions_by_new_epoch.get(&epoch).unwrap(); + deadline.add_expiration_partitions(rt.store(), epoch, p_idxs, quant).map_err( + |e| { + e.downcast_default( + ExitCode::USR_ILLEGAL_STATE, + format!( + "failed to add expiration partitions to deadline {} epoch {}", + deadline_idx, epoch + ), + ) + }, + )?; + } + + deadlines.update_deadline(policy, rt.store(), deadline_idx, &deadline).map_err( + |e| { + e.downcast_default( + ExitCode::USR_ILLEGAL_STATE, + format!("failed to save deadline {}", deadline_idx), + ) + }, + )?; + } + + state.sectors = sectors.amt.flush().map_err(|e| { + e.downcast_default(ExitCode::USR_ILLEGAL_STATE, "failed to save sectors") + })?; + state.save_deadlines(rt.store(), deadlines).map_err(|e| { + e.downcast_default(ExitCode::USR_ILLEGAL_STATE, "failed to save deadlines") + })?; + + // Lock the pledge top-up, checking funds before adding it so a shortfall + // reports the true available balance. Fee debt must be repaid in full in + // the same call. + let current_balance = rt.current_balance(); + if pledge_delta.is_positive() { + let available_balance = + state.get_available_balance(¤t_balance).map_err(|e| { + actor_error!(illegal_state, "failed to calculate available balance: {}", e) + })?; + if available_balance < pledge_delta { + return Err(actor_error!( + insufficient_funds, + "insufficient funds for aggregate initial pledge requirement {}, available: {}", + pledge_delta, + available_balance + )); + } + } + + state + .add_initial_pledge(&pledge_delta) + .map_err(|e| actor_error!(illegal_state, "failed to add initial pledge: {}", e))?; + + let fee_to_burn = repay_debts_or_abort(rt, state)?; + + state.check_balance_invariants(¤t_balance).map_err(balance_invariants_broken)?; + Ok((power_delta, pledge_delta, fee_to_burn)) + })?; + + burn_funds(rt, fee_to_burn)?; + // Unlike plain extension, the upgrade routinely adds power and pledge. + request_update_power(rt, power_delta)?; + notify_pledge_changed(rt, &pledge_delta)?; + + for decl in ¶ms.extensions { + for sector_number in decl.sectors.iter() { + emit::sector_updated(rt, sector_number, None, &[])?; + } + } + Ok(()) + } + /// Marks some sectors as terminated at the present epoch, earlier than their /// scheduled termination, and adds these sectors to the early termination queue. /// This method then processes up to AddressedSectorsMax sectors and @@ -3611,6 +3885,143 @@ fn extend_non_simple_qap_sector( Ok(new_sector) } +/// Validates `ExtendSectorExpiration3` declarations before any state changes: +/// deadline indices in range, no empty sector selections, requested expirations +/// inside the allowed window, and the batch under the addressed-partitions and +/// addressed-sectors limits. +fn validate_extension_declarations3( + rt: &impl Runtime, + extensions: &[ExpirationExtension3], +) -> Result<(), ActorError> { + let policy = rt.policy(); + let curr_epoch = rt.curr_epoch(); + + // Used only to validate the bitfields and count the batch. Declarations for + // the same partition must stay separate for execution, because each carries + // its own new expiration and this map would merge them. + let mut batch = DeadlineSectorMap::new(); + + for decl in extensions { + // Checked first: the deadline index guards vector indexing later. + if decl.deadline >= policy.wpost_period_deadlines { + return Err(actor_error!( + illegal_argument, + "deadline {} not in range 0..{}", + decl.deadline, + policy.wpost_period_deadlines + )); + } + + if decl.sectors.is_empty() { + return Err(actor_error!( + illegal_argument, + "no sectors selected in deadline {} partition {}", + decl.deadline, + decl.partition + )); + } + + // A requested extension must land after the current epoch and inside the + // maximum extension window. Checks against each sector's own expiration, + // activation and lifetime run per sector later. + if let Some(new_expiration) = decl.new_expiration { + if new_expiration <= curr_epoch { + return Err(actor_error!( + illegal_argument, + "new expiration {} must be after current epoch {}", + new_expiration, + curr_epoch + )); + } + if new_expiration > curr_epoch + policy.max_sector_expiration_extension { + return Err(actor_error!( + illegal_argument, + "new expiration {} cannot be more than {} past current epoch {}", + new_expiration, + policy.max_sector_expiration_extension, + curr_epoch + )); + } + } + + batch.add(policy, decl.deadline, decl.partition, decl.sectors.clone()).map_err(|e| { + actor_error!( + illegal_argument, + "failed to process deadline {}, partition {}: {}", + decl.deadline, + decl.partition, + e + ) + })?; + } + + batch.check(policy.addressed_partitions_max, policy.addressed_sectors_max).map_err(|e| { + actor_error!(illegal_argument, "cannot process requested parameters: {}", e) + })?; + + Ok(()) +} + +/// Builds the record for one sector upgraded to full quality-adjusted power +/// (FIP-0118) by `ExtendSectorExpiration3`, optionally extending it. +/// +/// Without a new expiration the record keeps its expiration, power base epoch and +/// weights, so the sector stays exactly where it is already scheduled; only the +/// flags, pledge and daily fee change. +/// +/// # Errors +/// Rejects sectors that have already expired, and expirations that would shorten +/// the sector's life or fall outside the allowed window. +#[allow(clippy::too_many_arguments)] +fn upgrade_sector_to_full_power( + policy: &Policy, + curr_epoch: ChainEpoch, + new_expiration: Option, + sector: &SectorOnChainInfo, + sector_size: SectorSize, + circulating_supply: &TokenAmount, + full_qa_power: &StoragePower, + full_power_pledge: &TokenAmount, +) -> Result { + // An upgrade-only sector keeps its own expiration but must pass the same + // validation, so expired-but-not-yet-removed sectors are rejected here too. + let effective_expiration = new_expiration.unwrap_or(sector.expiration); + validate_extended_expiration(policy, curr_epoch, effective_expiration, sector)?; + + let mut new_sector = match new_expiration { + None => sector.clone(), + // Same weight and power-base-epoch math as ExtendSectorExpiration2. + Some(new_expiration) => { + if sector.flags.contains(SectorOnChainInfoFlags::SIMPLE_QA_POWER) { + extend_simple_qap_sector(new_expiration, curr_epoch, sector) + } else { + extend_non_simple_qap_sector(new_expiration, curr_epoch, sector) + }? + } + }; + + // The flags are the entire power change: qa_power_for_sector returns the + // maximum for FULL_QA_POWER sectors. Same pair as replica updates set. + new_sector.flags |= + SectorOnChainInfoFlags::SIMPLE_QA_POWER | SectorOnChainInfoFlags::FULL_QA_POWER; + + // Pledge for the upgraded power, never lowered below what is already held. + new_sector.initial_pledge = max(new_sector.initial_pledge, full_power_pledge.clone()); + + if new_sector.daily_fee.is_zero() { + // Sector predates FIP-0100 fees; attach the fee at the upgraded rate. + new_sector.daily_fee = daily_proof_fee(policy, circulating_supply, full_qa_power); + } else { + // Scale the fee by the power change, reading the old power from the + // pre-upgrade record (the new one is already flagged and reports maximum). + let old_qa_power = qa_power_for_sector(sector_size, sector); + new_sector.daily_fee = + daily_proof_fee_adjust(&new_sector.daily_fee, &old_qa_power, full_qa_power); + } + + Ok(new_sector) +} + // Validates a list of replica update requests and parallel sector infos. // Returns all pairs of update and sector info, even those that fail validation. // The proof verification inputs are needed as witnesses to verify an aggregate proof to allow @@ -5539,6 +5950,7 @@ impl ActorCode for Actor { ProveCommitSectors3 => prove_commit_sectors3, ProveReplicaUpdates3 => prove_replica_updates3, ProveCommitSectorsNI => prove_commit_sectors_ni, + ExtendSectorExpiration3 => extend_sector_expiration3, MaxTerminationFeeExported => max_termination_fee, InitialPledgeExported => initial_pledge, GenerateSectorLocationExported => generate_sector_location, From e81dd7b166ea942511becfee77258b8199181f52 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Fri, 7 Aug 2026 12:38:47 +0530 Subject: [PATCH 3/4] test(miner): add ExtendSectorExpiration3 support to the test harness --- actors/miner/tests/util.rs | 136 +++++++++++++++++++++++++++++++++---- 1 file changed, 121 insertions(+), 15 deletions(-) diff --git a/actors/miner/tests/util.rs b/actors/miner/tests/util.rs index 06678bb94..46777a5a4 100644 --- a/actors/miner/tests/util.rs +++ b/actors/miner/tests/util.rs @@ -54,21 +54,21 @@ use fil_actor_miner::{ CompactPartitionsParams, CompactSectorNumbersParams, CronEventPayload, DataActivationNotification, Deadline, DeadlineInfo, Deadlines, DeclareFaultsParams, DeclareFaultsRecoveredParams, DeferredCronEventParams, DisputeWindowedPoStParams, - ExpirationQueue, ExpirationSet, ExtendSectorExpiration2Params, FaultDeclaration, - GenerateSectorLocationParams, GenerateSectorLocationReturn, GetAvailableBalanceReturn, - GetBeneficiaryReturn, GetControlAddressesReturn, GetMultiaddrsReturn, - GetNominalSectorExpirationReturn, GetPeerIDReturn, Method, Method as MinerMethod, - MinerConstructorParams as ConstructorParams, MinerInfo, NO_QUANTIZATION, Partition, - PendingBeneficiaryChange, PieceActivationManifest, PieceChange, PieceReturn, PoStPartition, - PowerPair, PreCommitSectorBatchParams, PreCommitSectorBatchParams2, PreCommitSectorParams, - ProveCommitSectorParams, ProveCommitSectors3Params, ProveCommitSectors3Return, QuantSpec, - RecoveryDeclaration, ReportConsensusFaultParams, SECTOR_CONTENT_CHANGED, SECTORS_AMT_BITWIDTH, - SectorActivationManifest, SectorChanges, SectorContentChangedParams, - SectorContentChangedReturn, SectorOnChainInfo, SectorPreCommitInfo, SectorPreCommitOnChainInfo, - SectorReturn, SectorStatusCode, SectorUpdateManifest, Sectors, State, SubmitWindowedPoStParams, - TerminateSectorsParams, TerminationDeclaration, ValidateSectorStatusParams, - ValidateSectorStatusReturn, VerifiedAllocationKey, WindowedPoSt, WithdrawBalanceParams, - WithdrawBalanceReturn, consensus_fault_penalty, ext, + ExpirationQueue, ExpirationSet, ExtendSectorExpiration2Params, ExtendSectorExpiration3Params, + FaultDeclaration, GenerateSectorLocationParams, GenerateSectorLocationReturn, + GetAvailableBalanceReturn, GetBeneficiaryReturn, GetControlAddressesReturn, + GetMultiaddrsReturn, GetNominalSectorExpirationReturn, GetPeerIDReturn, Method, + Method as MinerMethod, MinerConstructorParams as ConstructorParams, MinerInfo, NO_QUANTIZATION, + Partition, PendingBeneficiaryChange, PieceActivationManifest, PieceChange, PieceReturn, + PoStPartition, PowerPair, PreCommitSectorBatchParams, PreCommitSectorBatchParams2, + PreCommitSectorParams, ProveCommitSectorParams, ProveCommitSectors3Params, + ProveCommitSectors3Return, QuantSpec, RecoveryDeclaration, ReportConsensusFaultParams, + SECTOR_CONTENT_CHANGED, SECTORS_AMT_BITWIDTH, SectorActivationManifest, SectorChanges, + SectorContentChangedParams, SectorContentChangedReturn, SectorOnChainInfo, SectorPreCommitInfo, + SectorPreCommitOnChainInfo, SectorReturn, SectorStatusCode, SectorUpdateManifest, Sectors, + State, SubmitWindowedPoStParams, TerminateSectorsParams, TerminationDeclaration, + ValidateSectorStatusParams, ValidateSectorStatusReturn, VerifiedAllocationKey, WindowedPoSt, + WithdrawBalanceParams, WithdrawBalanceReturn, consensus_fault_penalty, ext, ext::market::ON_MINER_SECTORS_TERMINATE_METHOD, ext::power::UPDATE_CLAIMED_POWER_METHOD, initial_pledge_for_power, locked_reward_from_reward, max_prove_commit_duration, @@ -2561,6 +2561,112 @@ impl ActorHarness { Ok(ret) } + /// Calls `ExtendSectorExpiration3` as the worker, expecting the pledge-input + /// queries, a burn of any outstanding fee debt, the given power and pledge + /// deltas, and one `sector-updated` event per selected sector. + pub fn extend_sectors3( + &self, + rt: &MockRuntime, + params: ExtendSectorExpiration3Params, + expected_power_delta: PowerPair, + expected_pledge_delta: TokenAmount, + ) -> Result, ActorError> { + rt.set_caller(*ACCOUNT_ACTOR_CODE_ID, self.worker); + rt.expect_validate_caller_addr(self.caller_addrs()); + + self.expect_query_network_info(rt); + + let fee_debt = self.get_state(rt).fee_debt; + if fee_debt.is_positive() { + rt.expect_send_simple( + BURNT_FUNDS_ACTOR_ADDR, + METHOD_SEND, + None, + fee_debt, + None, + ExitCode::OK, + ); + } + + expect_update_power(rt, expected_power_delta); + if !expected_pledge_delta.is_zero() { + rt.expect_send_simple( + STORAGE_POWER_ACTOR_ADDR, + PowerMethod::UpdatePledgeTotal as u64, + IpldBlock::serialize_cbor(&expected_pledge_delta).unwrap(), + TokenAmount::zero(), + None, + ExitCode::OK, + ); + } + for extension in ¶ms.extensions { + for sector_number in extension.sectors.iter() { + expect_sector_event(rt, "sector-updated", §or_number, None, &vec![]); + } + } + + let ret = rt.call::( + Method::ExtendSectorExpiration3 as u64, + IpldBlock::serialize_cbor(¶ms).unwrap(), + )?; + + rt.verify(); + Ok(ret) + } + + /// Rewrites the given sectors' on-chain records with `edit`, moving the + /// partition expiration queue, deadline power and fee books, and the miner's + /// pledge total along with the change. Simulates cohorts no onboarding path + /// can produce anymore, e.g. pre-FIP-0118 sectors without `FULL_QA_POWER`. + pub fn rewrite_sectors( + &self, + rt: &MockRuntime, + sector_numbers: &[SectorNumber], + edit: impl Fn(&mut SectorOnChainInfo), + ) { + let mut state: State = rt.get_state(); + let store = rt.store(); + + let mut sectors = Sectors::load(store, &state.sectors).unwrap(); + let mut deadlines = state.load_deadlines(store).unwrap(); + + let mut pledge_delta = TokenAmount::zero(); + for §or_number in sector_numbers { + let (dl_idx, p_idx) = state.find_sector(store, sector_number).unwrap(); + let mut deadline = deadlines.load_deadline(store, dl_idx).unwrap(); + let mut partitions = deadline.partitions_amt(store).unwrap(); + let mut partition = partitions.get(p_idx).unwrap().cloned().unwrap(); + + let old_sector = sectors.must_get(sector_number).unwrap(); + let mut new_sector = old_sector.clone(); + edit(&mut new_sector); + + let quant = state.quant_spec_for_deadline(&rt.policy, dl_idx); + let (power_delta, partition_pledge_delta, fee_delta) = partition + .replace_sectors( + store, + std::slice::from_ref(&old_sector), + std::slice::from_ref(&new_sector), + self.sector_size, + quant, + ) + .unwrap(); + deadline.live_power += &power_delta; + deadline.daily_fee += &fee_delta; + pledge_delta += partition_pledge_delta; + + sectors.store(vec![new_sector]).unwrap(); + partitions.set(p_idx, partition).unwrap(); + deadline.partitions = partitions.flush().unwrap(); + deadlines.update_deadline(&rt.policy, store, dl_idx, &deadline).unwrap(); + } + + state.sectors = sectors.amt.flush().unwrap(); + state.save_deadlines(store, deadlines).unwrap(); + state.add_initial_pledge(&pledge_delta).unwrap(); + rt.replace_state(&state); + } + pub fn compact_partitions( &self, rt: &MockRuntime, From 1025a9c974ccd72adef90c53391bf495a37bb3ce Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Fri, 7 Aug 2026 12:38:47 +0530 Subject: [PATCH 4/4] test(miner): cover ExtendSectorExpiration3 upgrade and rejection paths --- .../tests/extend_sector_expiration3_test.rs | 618 ++++++++++++++++++ 1 file changed, 618 insertions(+) create mode 100644 actors/miner/tests/extend_sector_expiration3_test.rs diff --git a/actors/miner/tests/extend_sector_expiration3_test.rs b/actors/miner/tests/extend_sector_expiration3_test.rs new file mode 100644 index 000000000..d68891ca9 --- /dev/null +++ b/actors/miner/tests/extend_sector_expiration3_test.rs @@ -0,0 +1,618 @@ +use fil_actor_miner::{ + Actor, ExpirationExtension2, ExpirationExtension3, ExtendSectorExpiration2Params, + ExtendSectorExpiration3Params, Method, PowerPair, SectorOnChainInfo, SectorOnChainInfoFlags, + State, daily_proof_fee, daily_proof_fee_adjust, qa_power_for_sector, qa_power_max, +}; +use fil_actors_runtime::{ + EPOCHS_IN_DAY, + runtime::{Runtime, RuntimePolicy}, + test_utils::{ACCOUNT_ACTOR_CODE_ID, MockRuntime, expect_abort, expect_abort_contains_message}, +}; +use fvm_ipld_bitfield::BitField; +use fvm_ipld_encoding::ipld_block::IpldBlock; +use fvm_shared::address::Address; +use fvm_shared::bigint::BigInt; +use fvm_shared::clock::ChainEpoch; +use fvm_shared::econ::TokenAmount; +use fvm_shared::error::ExitCode; +use fvm_shared::sector::{RegisteredSealProof, SectorNumber, StoragePower}; + +use num_traits::Zero; +use std::collections::BTreeMap; + +mod util; +use util::*; + +// an expiration ~10 days greater than effective min expiration taking into account 30 days max between pre and prove commit +const DEFAULT_SECTOR_EXPIRATION: ChainEpoch = 220; + +fn setup() -> (ActorHarness, MockRuntime) { + let period_offset = 100; + let precommit_epoch = 1; + + let mut h = ActorHarness::new(period_offset); + h.set_proof_type(RegisteredSealProof::StackedDRG512MiBV1); + let rt = h.new_runtime(); + rt.balance.replace(BIG_BALANCE.clone()); + rt.set_epoch(precommit_epoch); + + (h, rt) +} + +/// Rewrites proven sectors as pre-FIP-0118 CC sectors: no `FULL_QA_POWER` flag, +/// pledge and daily fee at their 1x rates. Returns the rewritten records. +fn make_legacy( + h: &ActorHarness, + rt: &MockRuntime, + sectors: &[SectorOnChainInfo], +) -> Vec { + let raw_power = StoragePower::from(h.sector_size as u64); + let pledge_1x = h.initial_pledge_for_power(rt, &raw_power); + let fee_1x = daily_proof_fee(rt.policy(), &rt.total_fil_circ_supply(), &raw_power); + + let numbers: Vec<_> = sectors.iter().map(|s| s.sector_number).collect(); + h.rewrite_sectors(rt, &numbers, |sector| { + sector.flags = SectorOnChainInfoFlags::SIMPLE_QA_POWER; + sector.initial_pledge = pledge_1x.clone(); + sector.daily_fee = fee_1x.clone(); + }); + h.check_state(rt); + numbers.iter().map(|&n| h.get_sector(rt, n)).collect() +} + +fn commit_legacy_cc_sector(h: &mut ActorHarness, rt: &MockRuntime) -> SectorOnChainInfo { + h.construct_and_verify(rt); + let sector = + h.commit_and_prove_sectors(rt, 1, DEFAULT_SECTOR_EXPIRATION as u64, Vec::new(), true)[0] + .clone(); + h.advance_and_submit_posts(rt, std::slice::from_ref(§or)); + make_legacy(h, rt, &[sector]).remove(0) +} + +fn sector_location(rt: &MockRuntime, sector_number: SectorNumber) -> (u64, u64) { + let state: State = rt.get_state(); + state.find_sector(rt.store(), sector_number).unwrap() +} + +fn upgrade_params( + rt: &MockRuntime, + sector_number: SectorNumber, + new_expiration: Option, +) -> ExtendSectorExpiration3Params { + let (deadline, partition) = sector_location(rt, sector_number); + ExtendSectorExpiration3Params { + extensions: vec![ExpirationExtension3 { + deadline, + partition, + sectors: make_bitfield(&[sector_number]), + new_expiration, + }], + } +} + +/// Power and pledge deltas expected from upgrading these sectors to full power. +fn expected_upgrade_deltas( + h: &ActorHarness, + rt: &MockRuntime, + sectors: &[SectorOnChainInfo], +) -> (PowerPair, TokenAmount) { + let pledge_10x = h.initial_pledge_for_power(rt, &qa_power_max(h.sector_size)); + let mut qa_delta = BigInt::zero(); + let mut pledge_delta = TokenAmount::zero(); + for sector in sectors { + qa_delta += qa_power_max(h.sector_size) - qa_power_for_sector(h.sector_size, sector); + pledge_delta += &pledge_10x - §or.initial_pledge; + } + (PowerPair::new(BigInt::zero(), qa_delta), pledge_delta) +} + +#[test] +fn upgrade_only_upgrades_in_place() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + let (deadline_index, _) = sector_location(&rt, legacy.sector_number); + + let state_before: State = rt.get_state(); + let deadline_before = h.get_deadline(&rt, deadline_index); + + let (power_delta, pledge_delta) = + expected_upgrade_deltas(&h, &rt, std::slice::from_ref(&legacy)); + assert!(pledge_delta.is_positive()); + + h.extend_sectors3( + &rt, + upgrade_params(&rt, legacy.sector_number, None), + power_delta, + pledge_delta.clone(), + ) + .unwrap(); + + // Only the flags, pledge and fee changed on the record. + let upgraded = h.get_sector(&rt, legacy.sector_number); + assert!( + upgraded.flags.contains( + SectorOnChainInfoFlags::FULL_QA_POWER | SectorOnChainInfoFlags::SIMPLE_QA_POWER + ) + ); + let pledge_10x = h.initial_pledge_for_power(&rt, &qa_power_max(h.sector_size)); + assert_eq!(pledge_10x, upgraded.initial_pledge); + let qa_1x = qa_power_for_sector(h.sector_size, &legacy); + let fee_10x = daily_proof_fee_adjust(&legacy.daily_fee, &qa_1x, &qa_power_max(h.sector_size)); + assert_eq!(fee_10x, upgraded.daily_fee); + assert_eq!(legacy.expiration, upgraded.expiration); + assert_eq!(legacy.power_base_epoch, upgraded.power_base_epoch); + assert_eq!(legacy.deal_weight, upgraded.deal_weight); + assert_eq!(legacy.verified_deal_weight, upgraded.verified_deal_weight); + + // The pledge top-up is recorded on the miner total, and the deadline's + // expiration schedule was not rewritten. + let state_after: State = rt.get_state(); + assert_eq!(pledge_delta, &state_after.initial_pledge - &state_before.initial_pledge); + let deadline_after = h.get_deadline(&rt, deadline_index); + assert_eq!(deadline_before.expirations_epochs, deadline_after.expirations_epochs); + h.check_state(&rt); +} + +#[test] +fn upgrade_with_extension_in_one_declaration() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + let (deadline_index, _) = sector_location(&rt, legacy.sector_number); + let deadline_before = h.get_deadline(&rt, deadline_index); + + let new_expiration = legacy.expiration + 42 * EPOCHS_IN_DAY; + let (power_delta, pledge_delta) = + expected_upgrade_deltas(&h, &rt, std::slice::from_ref(&legacy)); + h.extend_sectors3( + &rt, + upgrade_params(&rt, legacy.sector_number, Some(new_expiration)), + power_delta, + pledge_delta, + ) + .unwrap(); + + let upgraded = h.get_sector(&rt, legacy.sector_number); + assert!(upgraded.flags.contains(SectorOnChainInfoFlags::FULL_QA_POWER)); + assert_eq!(new_expiration, upgraded.expiration); + assert_eq!(*rt.epoch.borrow(), upgraded.power_base_epoch); + + // The extension re-registered the partition in the deadline's schedule. + let deadline_after = h.get_deadline(&rt, deadline_index); + assert_ne!(deadline_before.expirations_epochs, deadline_after.expirations_epochs); + h.check_state(&rt); +} + +#[test] +fn second_upgrade_is_a_no_op() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + + let (power_delta, pledge_delta) = + expected_upgrade_deltas(&h, &rt, std::slice::from_ref(&legacy)); + h.extend_sectors3( + &rt, + upgrade_params(&rt, legacy.sector_number, None), + power_delta, + pledge_delta, + ) + .unwrap(); + let after_first = h.get_sector(&rt, legacy.sector_number); + + // The same call again moves no power, pledge or fee, but still succeeds. + h.extend_sectors3( + &rt, + upgrade_params(&rt, legacy.sector_number, None), + PowerPair::zero(), + TokenAmount::zero(), + ) + .unwrap(); + assert_eq!(after_first, h.get_sector(&rt, legacy.sector_number)); + h.check_state(&rt); +} + +#[test] +fn one_declaration_upgrades_sectors_with_different_expirations() { + let (mut h, rt) = setup(); + h.construct_and_verify(&rt); + let sectors = + h.commit_and_prove_sectors(&rt, 2, DEFAULT_SECTOR_EXPIRATION as u64, Vec::new(), true); + h.advance_and_submit_posts(&rt, §ors); + + // Push one sector's expiration out so the partition holds two different ones. + let (s1, s2) = (sectors[0].clone(), sectors[1].clone()); + let (deadline, partition) = sector_location(&rt, s2.sector_number); + h.extend_sectors2( + &rt, + ExtendSectorExpiration2Params { + extensions: vec![ExpirationExtension2 { + deadline, + partition, + sectors: make_bitfield(&[s2.sector_number]), + sectors_with_claims: vec![], + new_expiration: s2.expiration + 40 * EPOCHS_IN_DAY, + }], + }, + ) + .unwrap(); + let s2 = h.get_sector(&rt, s2.sector_number); + + let legacy = make_legacy(&h, &rt, &[s1, s2]); + assert_ne!(legacy[0].expiration, legacy[1].expiration); + + // One upgrade-only declaration per partition covers both expirations at once. + let mut by_partition: BTreeMap<(u64, u64), Vec> = BTreeMap::new(); + for sector in &legacy { + by_partition + .entry(sector_location(&rt, sector.sector_number)) + .or_default() + .push(sector.sector_number); + } + let params = ExtendSectorExpiration3Params { + extensions: by_partition + .iter() + .map(|(&(deadline, partition), sectors)| ExpirationExtension3 { + deadline, + partition, + sectors: make_bitfield(sectors), + new_expiration: None, + }) + .collect(), + }; + + let (power_delta, pledge_delta) = expected_upgrade_deltas(&h, &rt, &legacy); + h.extend_sectors3(&rt, params, power_delta, pledge_delta).unwrap(); + + for sector in &legacy { + let upgraded = h.get_sector(&rt, sector.sector_number); + assert!(upgraded.flags.contains(SectorOnChainInfoFlags::FULL_QA_POWER)); + assert_eq!(sector.expiration, upgraded.expiration); + } + h.check_state(&rt); +} + +#[test] +fn duplicate_sector_across_declarations_upgrades_once() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + let (deadline, partition) = sector_location(&rt, legacy.sector_number); + + let decl = ExpirationExtension3 { + deadline, + partition, + sectors: make_bitfield(&[legacy.sector_number]), + new_expiration: None, + }; + let params = ExtendSectorExpiration3Params { extensions: vec![decl.clone(), decl] }; + + // Exactly one power bump and one pledge raise despite two declarations. + let state_before: State = rt.get_state(); + let (power_delta, pledge_delta) = + expected_upgrade_deltas(&h, &rt, std::slice::from_ref(&legacy)); + h.extend_sectors3(&rt, params, power_delta, pledge_delta.clone()).unwrap(); + + let state_after: State = rt.get_state(); + assert_eq!(pledge_delta, &state_after.initial_pledge - &state_before.initial_pledge); + h.check_state(&rt); +} + +#[test] +fn rejects_empty_sector_selection() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + let (deadline_index, partition) = sector_location(&rt, legacy.sector_number); + let deadline_before = h.get_deadline(&rt, deadline_index); + + // Even a huge requested expiration must fail cleanly without touching state. + for new_expiration in [None, Some(i64::MAX)] { + let params = ExtendSectorExpiration3Params { + extensions: vec![ExpirationExtension3 { + deadline: deadline_index, + partition, + sectors: BitField::new(), + new_expiration, + }], + }; + let res = h.extend_sectors3(&rt, params, PowerPair::zero(), TokenAmount::zero()); + expect_abort_contains_message(ExitCode::USR_ILLEGAL_ARGUMENT, "no sectors selected", res); + rt.reset(); + } + + let deadline_after = h.get_deadline(&rt, deadline_index); + assert_eq!(deadline_before.expirations_epochs, deadline_after.expirations_epochs); + h.check_state(&rt); +} + +#[test] +fn rejects_expiration_at_or_before_current_epoch() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + let curr_epoch = *rt.epoch.borrow(); + + for bad_expiration in [0, -1, curr_epoch] { + let res = h.extend_sectors3( + &rt, + upgrade_params(&rt, legacy.sector_number, Some(bad_expiration)), + PowerPair::zero(), + TokenAmount::zero(), + ); + expect_abort_contains_message( + ExitCode::USR_ILLEGAL_ARGUMENT, + "must be after current epoch", + res, + ); + rt.reset(); + } + + let after = h.get_sector(&rt, legacy.sector_number); + assert!(!after.flags.contains(SectorOnChainInfoFlags::FULL_QA_POWER)); + h.check_state(&rt); +} + +#[test] +fn rejects_expiration_beyond_max_extension() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + let too_far = *rt.epoch.borrow() + rt.policy().max_sector_expiration_extension + 1; + + let res = h.extend_sectors3( + &rt, + upgrade_params(&rt, legacy.sector_number, Some(too_far)), + PowerPair::zero(), + TokenAmount::zero(), + ); + expect_abort_contains_message(ExitCode::USR_ILLEGAL_ARGUMENT, "cannot be more than", res); + rt.reset(); + h.check_state(&rt); +} + +#[test] +fn rejects_reducing_expiration() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + + let res = h.extend_sectors3( + &rt, + upgrade_params(&rt, legacy.sector_number, Some(legacy.expiration - 1)), + PowerPair::zero(), + TokenAmount::zero(), + ); + expect_abort_contains_message( + ExitCode::USR_ILLEGAL_ARGUMENT, + &format!("cannot reduce sector {} expiration", legacy.sector_number), + res, + ); + rt.reset(); + h.check_state(&rt); +} + +#[test] +fn rejects_faulty_sector() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + h.declare_faults(&rt, std::slice::from_ref(&legacy)); + + let res = h.extend_sectors3( + &rt, + upgrade_params(&rt, legacy.sector_number, None), + PowerPair::zero(), + TokenAmount::zero(), + ); + expect_abort_contains_message( + ExitCode::USR_ILLEGAL_ARGUMENT, + "can only upgrade active sectors", + res, + ); + rt.reset(); + h.check_state(&rt); +} + +#[test] +fn insufficient_balance_aborts_whole_message() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + let (_, pledge_delta) = expected_upgrade_deltas(&h, &rt, std::slice::from_ref(&legacy)); + + // One atto short of the required top-up. + let state: State = rt.get_state(); + rt.balance.replace( + &state.initial_pledge + &state.locked_funds + &state.pre_commit_deposits + &pledge_delta + - TokenAmount::from_atto(1), + ); + + let res = h.extend_sectors3( + &rt, + upgrade_params(&rt, legacy.sector_number, None), + PowerPair::zero(), + TokenAmount::zero(), + ); + expect_abort_contains_message( + ExitCode::USR_INSUFFICIENT_FUNDS, + "insufficient funds for aggregate initial pledge requirement", + res, + ); + rt.reset(); + + // Nothing changed. + let after = h.get_sector(&rt, legacy.sector_number); + assert!(!after.flags.contains(SectorOnChainInfoFlags::FULL_QA_POWER)); + assert_eq!(legacy.initial_pledge, after.initial_pledge); + + rt.balance.replace(BIG_BALANCE.clone()); + h.check_state(&rt); +} + +#[test] +fn fee_debt_counts_against_available_balance() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + let (_, pledge_delta) = expected_upgrade_deltas(&h, &rt, std::slice::from_ref(&legacy)); + + // Unlocked balance covers the top-up but not the fee debt as well, so the + // debt-aware check must reject the upgrade. + let debt = TokenAmount::from_whole(5); + let mut state: State = rt.get_state(); + state.fee_debt = debt.clone(); + rt.balance.replace( + &state.initial_pledge + + &state.locked_funds + + &state.pre_commit_deposits + + &pledge_delta + + &debt + - TokenAmount::from_atto(1), + ); + rt.replace_state(&state); + + let res = h.extend_sectors3( + &rt, + upgrade_params(&rt, legacy.sector_number, None), + PowerPair::zero(), + TokenAmount::zero(), + ); + expect_abort_contains_message( + ExitCode::USR_INSUFFICIENT_FUNDS, + "insufficient funds for aggregate initial pledge requirement", + res, + ); + rt.reset(); + + rt.balance.replace(BIG_BALANCE.clone()); + h.check_state(&rt); +} + +#[test] +fn repays_fee_debt_and_locks_pledge() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + + let debt = TokenAmount::from_whole(5); + let mut state: State = rt.get_state(); + state.fee_debt = debt.clone(); + rt.replace_state(&state); + + // The harness expects the fee-debt burn along with the upgrade effects. + let (power_delta, pledge_delta) = + expected_upgrade_deltas(&h, &rt, std::slice::from_ref(&legacy)); + h.extend_sectors3( + &rt, + upgrade_params(&rt, legacy.sector_number, None), + power_delta, + pledge_delta, + ) + .unwrap(); + + let state: State = rt.get_state(); + assert!(state.fee_debt.is_zero()); + assert!( + h.get_sector(&rt, legacy.sector_number) + .flags + .contains(SectorOnChainInfoFlags::FULL_QA_POWER) + ); + h.check_state(&rt); +} + +#[test] +fn attaches_full_rate_fee_to_pre_fip0100_sector() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + h.rewrite_sectors(&rt, &[legacy.sector_number], |sector| { + sector.daily_fee = TokenAmount::zero() + }); + + let (power_delta, pledge_delta) = + expected_upgrade_deltas(&h, &rt, std::slice::from_ref(&legacy)); + h.extend_sectors3( + &rt, + upgrade_params(&rt, legacy.sector_number, None), + power_delta, + pledge_delta, + ) + .unwrap(); + + let expected_fee = + daily_proof_fee(rt.policy(), &rt.total_fil_circ_supply(), &qa_power_max(h.sector_size)); + assert_eq!(expected_fee, h.get_sector(&rt, legacy.sector_number).daily_fee); + h.check_state(&rt); +} + +#[test] +fn extend_sector_expiration2_does_not_upgrade() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + let (deadline, partition) = sector_location(&rt, legacy.sector_number); + + let new_expiration = legacy.expiration + 42 * EPOCHS_IN_DAY; + let params = ExtendSectorExpiration2Params { + extensions: vec![ExpirationExtension2 { + deadline, + partition, + sectors: make_bitfield(&[legacy.sector_number]), + sectors_with_claims: vec![], + new_expiration, + }], + }; + h.extend_sectors2(&rt, params).unwrap(); + + // The plain extension contract holds: no flag, no pledge or fee change. + let extended = h.get_sector(&rt, legacy.sector_number); + assert!(!extended.flags.contains(SectorOnChainInfoFlags::FULL_QA_POWER)); + assert_eq!(new_expiration, extended.expiration); + assert_eq!(legacy.initial_pledge, extended.initial_pledge); + assert_eq!(legacy.daily_fee, extended.daily_fee); + h.check_state(&rt); +} + +#[test] +fn rejects_batches_beyond_addressing_limits() { + let (h, rt) = setup(); + h.construct_and_verify(&rt); + + // One partition too many. + let extensions: Vec<_> = (0..=rt.policy().addressed_partitions_max) + .map(|i| ExpirationExtension3 { + deadline: 0, + partition: i, + sectors: make_bitfield(&[i]), + new_expiration: None, + }) + .collect(); + let res = h.extend_sectors3( + &rt, + ExtendSectorExpiration3Params { extensions }, + PowerPair::zero(), + TokenAmount::zero(), + ); + expect_abort_contains_message(ExitCode::USR_ILLEGAL_ARGUMENT, "too many partitions", res); + rt.reset(); + + // One sector too many. + let params = ExtendSectorExpiration3Params { + extensions: vec![ExpirationExtension3 { + deadline: 0, + partition: 0, + sectors: BitField::try_from_bits(0..=rt.policy().addressed_sectors_max).unwrap(), + new_expiration: None, + }], + }; + let res = h.extend_sectors3(&rt, params, PowerPair::zero(), TokenAmount::zero()); + expect_abort_contains_message(ExitCode::USR_ILLEGAL_ARGUMENT, "too many sectors", res); + rt.reset(); +} + +#[test] +fn rejects_unauthorized_caller() { + let (mut h, rt) = setup(); + let legacy = commit_legacy_cc_sector(&mut h, &rt); + let params = upgrade_params(&rt, legacy.sector_number, None); + + rt.set_caller(*ACCOUNT_ACTOR_CODE_ID, Address::new_id(1234)); + rt.expect_validate_caller_addr(h.caller_addrs()); + // Pledge inputs are fetched before the transaction validates the caller. + h.expect_query_network_info(&rt); + + let res = rt.call::( + Method::ExtendSectorExpiration3 as u64, + IpldBlock::serialize_cbor(¶ms).unwrap(), + ); + expect_abort(ExitCode::USR_FORBIDDEN, res); + h.check_state(&rt); +}