From aa9ec59147c1356f13cc8db31809d761c3f5b2fe Mon Sep 17 00:00:00 2001 From: Ilia Churin Date: Tue, 7 Jul 2026 13:52:21 +0900 Subject: [PATCH 01/20] Measure pallet timeouts in relay chain blocks, not parachain blocks Add a BlockNumberProvider associated type to pallet-storage-provider's Config and route every block-number read in the pallet (and the drive/s3 registries, which reuse it via their Config supertrait) through it. Production runtimes supply RelaychainDataProvider, so all timeouts, expiries, checkpoint windows and nonce-recency checks are measured in relay chain blocks (6s) and keep their wall-clock meaning when the parachain block time changes (#113 / PR #131). Mocks supply System. The challenge slash sweep can no longer probe the single key n in on_finalize(n): relay numbers advance by a variable amount (including zero) between consecutive parachain blocks. Replace it with a drain-range sweep in on_initialize that tracks progress in a new LastSweptChallengeBlock cursor, drains all deadline keys strictly below the previous block's relay parent (provably unrespondable), and returns the exact weight consumed instead of pre-reserving it. Two independent bounds keep the block budget safe: a 32-key span cap on probing and a MaxChallengesPerDeadline slash budget per block (the old single-key worst case), with mid-key carry-over via the cursor. Runtime timing constants now derive from a new relay_time module based on RELAY_CHAIN_SLOT_DURATION_MILLIS instead of the parachain MILLISECS_PER_BLOCK; values are numerically unchanged today. Benchmarks advance both clocks via a set_block_number helper (the provider implements set_block_number under runtime-benchmarks), and the paseo runtime tests gain a set_clocks helper for the same reason. Closes #233 --- pallet/src/benchmarking.rs | 27 +- pallet/src/impls/agreements.rs | 4 +- pallet/src/impls/buckets.rs | 2 +- pallet/src/impls/challenges.rs | 2 +- pallet/src/impls/providers.rs | 2 +- pallet/src/impls/queries.rs | 10 + pallet/src/impls/signatures.rs | 2 +- pallet/src/lib.rs | 254 ++++++++++++------ pallet/src/mock.rs | 5 +- pallet/src/tests/challenge.rs | 218 ++++++++++++++- runtimes/web3-storage-local/src/constants.rs | 12 + runtimes/web3-storage-local/src/storage.rs | 18 +- .../web3-storage-paseo/src/paseo_constants.rs | 12 + runtimes/web3-storage-paseo/src/storage.rs | 19 +- runtimes/web3-storage-paseo/tests/tests.rs | 26 +- .../pallet-registry/src/bechmarking.rs | 2 +- .../file-system/pallet-registry/src/lib.rs | 2 +- .../file-system/pallet-registry/src/mock.rs | 1 + .../s3/pallet-s3-registry/src/benchmarking.rs | 4 +- .../s3/pallet-s3-registry/src/lib.rs | 7 +- .../s3/pallet-s3-registry/src/mock.rs | 1 + 21 files changed, 507 insertions(+), 123 deletions(-) diff --git a/pallet/src/benchmarking.rs b/pallet/src/benchmarking.rs index 725a517c..a6482cdb 100644 --- a/pallet/src/benchmarking.rs +++ b/pallet/src/benchmarking.rs @@ -19,6 +19,15 @@ use storage_primitives::{ const SEED: u32 = 0; +/// Advance the clock the pallet logic reads. `System` and the configured +/// [`Config::BlockNumberProvider`] are distinct clocks in a real runtime, so +/// benchmarks must advance both. +fn set_block_number(n: BlockNumberFor) { + use sp_runtime::traits::BlockNumberProvider; + System::::set_block_number(n); + T::BlockNumberProvider::set_block_number(n); +} + /// Key type used by the benchmarking keystore for provider signing material. const KEY_TYPE: sp_core::crypto::KeyTypeId = sp_core::crypto::KeyTypeId(*b"bnch"); @@ -79,7 +88,7 @@ fn build_primary_terms( max_bytes, duration, price_per_byte: 1u32.into(), - valid_until: System::::block_number().saturating_add(T::RequestTimeout::get()), + valid_until: StorageProvider::::current_block().saturating_add(T::RequestTimeout::get()), nonce, bucket_id: None, replica_params: None, @@ -99,7 +108,7 @@ fn build_replica_terms( max_bytes, duration, price_per_byte: 1u32.into(), - valid_until: System::::block_number().saturating_add(T::RequestTimeout::get()), + valid_until: StorageProvider::::current_block().saturating_add(T::RequestTimeout::get()), nonce, bucket_id: Some(bucket_id), replica_params: Some(ReplicaTerms { @@ -176,7 +185,7 @@ fn add_primary_to_bucket( bucket_id: BucketId, max_bytes: u64, ) { - let current_block = System::::block_number(); + let current_block = StorageProvider::::current_block(); let duration: BlockNumberFor = 100u32.into(); let expires_at = current_block.saturating_add(duration); @@ -322,9 +331,9 @@ mod benchmarks { let _ = Pallet::::deregister_provider(RawOrigin::Signed(provider.clone()).into()); // Advance past `DeregisterAnnouncementPeriod` so completion is allowed. - let announce_block = System::::block_number(); + let announce_block = StorageProvider::::current_block(); let complete_after = announce_block.saturating_add(T::DeregisterAnnouncementPeriod::get()); - System::::set_block_number(complete_after); + set_block_number::(complete_after); #[extrinsic_call] complete_deregister(RawOrigin::Signed(provider)); @@ -602,13 +611,13 @@ mod benchmarks { // Advance block past agreement expiry + settlement timeout let agreement = StorageProvider::::storage_agreements(bucket_id, &provider).unwrap(); - let current_block = System::::block_number(); + let current_block = StorageProvider::::current_block(); let target_block: BlockNumberFor = agreement .expires_at .saturating_add(T::SettlementTimeout::get()) .saturating_add(current_block) .saturating_add(1u32.into()); - System::::set_block_number(target_block); + set_block_number::(target_block); #[extrinsic_call] claim_expired_agreement(RawOrigin::Signed(provider), bucket_id); @@ -773,7 +782,7 @@ mod benchmarks { let target_block: BlockNumberFor = window_start .saturating_add(grace_period) .saturating_add(1u32.into()); - System::::set_block_number(target_block); + set_block_number::(target_block); // Sign the CheckpointProposal with all s providers. let proposal = @@ -838,7 +847,7 @@ mod benchmarks { let interval = T::DefaultCheckpointInterval::get(); let next_window_start = interval.saturating_mul((window + 1).saturated_into()); let target_block: BlockNumberFor = next_window_start.saturating_add(1u32.into()); - System::::set_block_number(target_block); + set_block_number::(target_block); #[extrinsic_call] report_missed_checkpoint(RawOrigin::Signed(admin), bucket_id, window); diff --git a/pallet/src/impls/agreements.rs b/pallet/src/impls/agreements.rs index 7ef97848..22dc4c39 100644 --- a/pallet/src/impls/agreements.rs +++ b/pallet/src/impls/agreements.rs @@ -167,7 +167,7 @@ impl Pallet { // Quote must not be stale and must not exceed the chain-enforced window. // `terms.valid_until` must in range [current_block, current_block + RequestTimeout] - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); ensure!(terms.valid_until >= current_block, Error::::TermsExpired); ensure!( terms.valid_until <= current_block.saturating_add(T::RequestTimeout::get()), @@ -297,7 +297,7 @@ impl Pallet { ensure!(terms.max_bytes > 0, Error::::InvalidMaxBytesRequest); // Quote must not be stale and must not exceed the chain-enforced window. - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); ensure!(terms.valid_until >= current_block, Error::::TermsExpired); ensure!( terms.valid_until <= current_block.saturating_add(T::RequestTimeout::get()), diff --git a/pallet/src/impls/buckets.rs b/pallet/src/impls/buckets.rs index dc72f6b6..2f2c1e3b 100644 --- a/pallet/src/impls/buckets.rs +++ b/pallet/src/impls/buckets.rs @@ -44,7 +44,7 @@ impl Pallet { for (provider, agreement) in agreements { // Calculate prorated refund based on remaining time - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); let remaining_blocks = agreement.expires_at.saturating_sub(current_block); // If there's remaining time, calculate prorated refund diff --git a/pallet/src/impls/challenges.rs b/pallet/src/impls/challenges.rs index 64120c50..8516d0ec 100644 --- a/pallet/src/impls/challenges.rs +++ b/pallet/src/impls/challenges.rs @@ -28,7 +28,7 @@ impl Pallet { T::Currency::reserve(&challenger, deposit)?; - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); let deadline = current_block.saturating_add(T::ChallengeTimeout::get()); let challenge = Challenge { diff --git a/pallet/src/impls/providers.rs b/pallet/src/impls/providers.rs index b50340d3..7e3e39b7 100644 --- a/pallet/src/impls/providers.rs +++ b/pallet/src/impls/providers.rs @@ -86,7 +86,7 @@ impl Pallet { // Reserve stake T::Currency::reserve(who, stake)?; - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); let provider_info = ProviderInfo { multiaddr, diff --git a/pallet/src/impls/queries.rs b/pallet/src/impls/queries.rs index 5ab6cf72..21ede9d0 100644 --- a/pallet/src/impls/queries.rs +++ b/pallet/src/impls/queries.rs @@ -62,6 +62,16 @@ fn agreement_to_response( } impl Pallet { + /// Current block number on the clock all pallet durations are measured + /// in: the relay chain in production, `System` in tests. + /// + /// During `on_initialize` this returns the *previous* block's relay + /// parent (the validation-data inherent has not run yet); everywhere + /// else it is the current block's relay parent. + pub fn current_block() -> BlockNumberFor { + ::current_block_number() + } + /// Query provider information. pub fn query_provider_info( provider: &T::AccountId, diff --git a/pallet/src/impls/signatures.rs b/pallet/src/impls/signatures.rs index f98e9cfd..11da3fa4 100644 --- a/pallet/src/impls/signatures.rs +++ b/pallet/src/impls/signatures.rs @@ -17,7 +17,7 @@ impl Pallet { /// of) the current block. This prevents an attacker who captures one /// signed commitment from replaying it forever. pub(crate) fn ensure_recent_nonce(nonce: u64) -> DispatchResult { - let current: u64 = frame_system::Pallet::::block_number().saturated_into(); + let current: u64 = Self::current_block().saturated_into(); let max_age: u64 = T::MaxNonceAge::get().saturated_into(); // Future-dated nonces are nonsensical — the signer can only know // the current block at sign-time. Allow exact equality. diff --git a/pallet/src/lib.rs b/pallet/src/lib.rs index 6f7777b6..42b7b95f 100644 --- a/pallet/src/lib.rs +++ b/pallet/src/lib.rs @@ -53,7 +53,7 @@ pub mod pallet { }; use frame_system::pallet_prelude::*; use sp_core::H256; - use sp_runtime::traits::{Bounded, CheckedAdd, Saturating, Zero}; + use sp_runtime::traits::{Bounded, CheckedAdd, One, Saturating, Zero}; use storage_primitives::{ BucketId, BucketSnapshot, ChallengeId, ChallengerStatRecord, ChunkLocation, Commitment, CommitmentPayload, EndAction, MerkleProof, MmrProof, ProviderRole, RemovalReason, @@ -74,51 +74,126 @@ pub mod pallet { #[pallet::pallet] pub struct Pallet(_); + /// Maximum number of deadline keys the challenge slash sweep drains per + /// block. Relay block numbers can jump by more than one between + /// consecutive parachain blocks (most drastically after a gap in block + /// production), so the sweep covers a range of keys; this cap bounds the + /// per-block key probing and the remainder carries over via + /// [`LastSweptChallengeBlock`]. The expensive part — slashing — is + /// bounded separately by the per-block challenge budget + /// (`MaxChallengesPerDeadline`) inside `on_initialize`. + const MAX_SWEEP_SPAN: u32 = 32; + #[pallet::hooks] impl Hooks> for Pallet { - /// Reserve weight for the bounded challenge sweep that `on_finalize(n)` - /// performs this block. + /// Slash providers whose challenges expired unanswered. /// - /// The sweep drains every challenge whose deadline is `n` and slashes - /// its provider. Every challenge targeting deadline `n` is created - /// strictly before block `n` (`deadline = created_at + ChallengeTimeout` - /// and `ChallengeTimeout > 0`), so `NextChallengeIndex(n)` — the count - /// of challenges ever allocated for that deadline — is already final - /// here and is capped at `MaxChallengesPerDeadline` by - /// `create_challenge`. That makes the sweep's work bounded and lets us - /// account for it up front instead of doing unbounded work in - /// `on_finalize` without a weight charge. - fn on_initialize(n: BlockNumberFor) -> Weight { - // `NextChallengeIndex(n)` is the final count of challenges expiring - // at `n` (every such challenge was created strictly before `n` and - // is capped at `MaxChallengesPerDeadline`). Charge the benchmarked - // cost of the `on_finalize` slash sweep up front, since - // `on_finalize` cannot return weight. - let count = NextChallengeIndex::::get(n); - T::WeightInfo::on_initialize_slash_challenges(count as u32) - } - - /// Process expired challenges at the end of each block. - fn on_finalize(n: BlockNumberFor) { - // Drain every challenge expiring at this block by its stable - // per-deadline index and slash the provider for failing to - // respond. `drain_prefix` removes the entries as it iterates. - for (index, challenge) in Challenges::::drain_prefix(n) { - let challenge_id = ChallengeId { deadline: n, index }; - // Timeout is a resolution: decrement the pending counters - // exactly once here, mirroring the increment in - // `create_challenge`. (The slash helper is shared with the - // invalid-response path, so it must NOT touch the counters.) - Self::decrement_pending(challenge.bucket_id, &challenge.provider); - Self::slash_provider_for_failed_challenge( - &challenge, - challenge_id, - SlashReason::Timeout, - ); + /// Challenge deadlines are relay-chain block numbers + /// ([`Config::BlockNumberProvider`]), which advance by a variable + /// amount (including zero) between consecutive parachain blocks, so + /// the sweep drains a *range* of deadline keys and tracks its + /// progress in [`LastSweptChallengeBlock`] instead of probing the + /// single key `n` the way a parachain-block-keyed sweep could. + /// + /// At `on_initialize` time the validation-data inherent has not run + /// yet, so [`Pallet::current_block`] returns the relay parent `p` of + /// the *previous* parachain block. A challenge with deadline `d` is + /// respondable in any block whose relay parent is `<= d`, and every + /// later block has relay parent `>= p`, so exactly the keys `< p` + /// are final here: unrespondable, with `NextChallengeIndex` frozen + /// (any new challenge gets `deadline = now + ChallengeTimeout >= p`). + /// Draining them cannot race a valid response. The flip side is a + /// one-parachain-block lag: a slash lands in the first block *after* + /// the relay parent passes the deadline. Escape hatches don't care — + /// `complete_deregister`/`end_agreement` are gated by the + /// [`PendingChallenges`] counters, not by the sweep having run. + /// + /// Two independent bounds keep the block budget safe: [`MAX_SWEEP_SPAN`] + /// caps how many keys are probed, and a challenge budget of + /// [`Config::MaxChallengesPerDeadline`] caps how many slashes run — + /// the same worst case a single fully-loaded deadline always had. On + /// budget exhaustion the cursor parks just below the partially + /// drained key and the remainder carries over to later blocks. + /// + /// Runs in `on_initialize` rather than `on_finalize` so the actual + /// work done can be returned as weight instead of pre-reserved. + fn on_initialize(_n: BlockNumberFor) -> Weight { + // Provider read + cursor read/write. + let mut weight = T::DbWeight::get().reads_writes(2, 1); + + let now = Self::current_block(); + if now.is_zero() { + return weight; + } + let sweepable = now.saturating_sub(One::one()); + let last = match LastSweptChallengeBlock::::get() { + Some(last) => last, + None => { + // First run: anchor the cursor here instead of scanning + // up from zero (relay numbers start in the millions on + // live networks). On a fresh chain nothing can be pending + // below it; upgrading a live chain with in-flight + // parachain-denominated challenges instead needs a + // migration re-keying them above the anchor, or they are + // stranded below the cursor and never swept. + LastSweptChallengeBlock::::put(sweepable); + return weight; + } + }; + if sweepable <= last { + return weight; } - // Clear the per-deadline index allocator now the deadline has - // passed; no further challenges can target this block. - NextChallengeIndex::::remove(n); + let end = sweepable.min(last.saturating_add(MAX_SWEEP_SPAN.into())); + // Per-block slash budget. `.max(1)` so a (nonsensical) zero cap + // cannot park the cursor forever. + let mut budget = u32::from(T::MaxChallengesPerDeadline::get()).max(1); + let mut key = last.saturating_add(One::one()); + while key <= end { + let mut count: u32 = 0; + // Drain every challenge expiring at this key by its stable + // per-deadline index and slash the provider for failing to + // respond. `drain_prefix` removes the entries as it iterates. + for (index, challenge) in Challenges::::drain_prefix(key) { + let challenge_id = ChallengeId { + deadline: key, + index, + }; + // Timeout is a resolution: decrement the pending counters + // exactly once here, mirroring the increment in + // `create_challenge`. (The slash helper is shared with the + // invalid-response path, so it must NOT touch the counters.) + Self::decrement_pending(challenge.bucket_id, &challenge.provider); + Self::slash_provider_for_failed_challenge( + &challenge, + challenge_id, + SlashReason::Timeout, + ); + count = count.saturating_add(1); + if count >= budget { + break; + } + } + budget = budget.saturating_sub(count); + // Benchmarked for a single key's drain; applying it per key + // over-charges the fixed overhead. Conservative. + weight = + weight.saturating_add(T::WeightInfo::on_initialize_slash_challenges(count)); + if budget == 0 { + // Budget exhausted, possibly mid-key (`drain_prefix` + // removed the entries already visited). Keep the key's + // `NextChallengeIndex` allocator and park the cursor just + // below it; the remainder and the allocator cleanup drain + // on later blocks. + LastSweptChallengeBlock::::put(key.saturating_sub(One::one())); + return weight; + } + // Clear the per-deadline index allocator now the deadline has + // passed; no further challenges can target this key. + NextChallengeIndex::::remove(key); + key = key.saturating_add(One::one()); + } + LastSweptChallengeBlock::::put(end); + weight } fn integrity_test() { @@ -178,7 +253,8 @@ pub mod pallet { #[pallet::constant] type MaxChunkSize: Get; - /// Timeout for challenge response (e.g., ~48 hours in blocks). + /// Timeout for challenge response (e.g., ~48 hours in relay chain + /// blocks). #[pallet::constant] type ChallengeTimeout: Get>; @@ -192,26 +268,32 @@ pub mod pallet { #[pallet::constant] type ChallengeDeposit: Get>; - /// Maximum age of a `CommitmentPayload::nonce` (in blocks) the pallet - /// will accept on inbound signatures. The nonce is the block number - /// at which the signer signed; values older than this are rejected - /// to prevent indefinite signature replay. + /// Maximum age of a `CommitmentPayload::nonce` (in relay chain + /// blocks) the pallet will accept on inbound signatures. The nonce is + /// the relay chain block number (per + /// [`Config::BlockNumberProvider`]) at which the signer signed; + /// values older than this are rejected to prevent indefinite + /// signature replay. #[pallet::constant] type MaxNonceAge: Get>; - /// Settlement window after agreement expiry for owner to call end_agreement. + /// Settlement window (in relay chain blocks) after agreement expiry + /// for owner to call end_agreement. #[pallet::constant] type SettlementTimeout: Get>; - /// Maximum duration for agreement requests before expiry. + /// Maximum duration (in relay chain blocks) for agreement requests + /// before expiry. #[pallet::constant] type RequestTimeout: Get>; - /// Default interval between provider-initiated checkpoints (e.g., 100 blocks). + /// Default interval between provider-initiated checkpoints (e.g., 100 + /// relay chain blocks). #[pallet::constant] type DefaultCheckpointInterval: Get>; - /// Default grace period for checkpoint leader (e.g., 20 blocks). + /// Default grace period for checkpoint leader (e.g., 20 relay chain + /// blocks). #[pallet::constant] type DefaultCheckpointGrace: Get>; @@ -227,24 +309,38 @@ pub mod pallet { #[pallet::constant] type MaxBucketsPerMember: Get; - /// Minimum number of blocks between announcing a deregistration and - /// being allowed to complete it. Must be `> ChallengeTimeout` so any + /// Minimum number of relay chain blocks between announcing a + /// deregistration and being allowed to complete it. Must be + /// `> ChallengeTimeout` so any /// challenge against this provider that was created up to the /// announcement block matures while the provider is still slashable. #[pallet::constant] type DeregisterAnnouncementPeriod: Get>; - /// Maximum number of challenges that may share a single deadline block. + /// Maximum number of challenges that may share a single deadline + /// (relay chain block), and the per-block slash budget of the + /// `on_initialize` timeout sweep. /// - /// Bounds the per-deadline challenge count so the `on_finalize` slash - /// sweep — which drains and slashes every challenge expiring at a given - /// block — does a bounded amount of work whose weight `on_initialize` - /// can reserve up front. Only challenges created in the *same* block - /// share a deadline (`deadline = created_at + ChallengeTimeout`), so a - /// generous value still cannot be exceeded under honest load. + /// Bounds the per-deadline challenge count at creation, and the sweep + /// never slashes more than this many challenges per block regardless + /// of how many deadline keys a gap matured at once — so the worst + /// case per block equals one fully-loaded deadline. Note that + /// consecutive parachain blocks can share a relay parent, so + /// challenges created in different parachain blocks may share a + /// deadline; the bound is this explicit cap, not block co-location. #[pallet::constant] type MaxChallengesPerDeadline: Get; + /// Source of the block number every timeout, expiry and interval in + /// this pallet is measured against. Production runtimes supply the + /// relay chain block number + /// (`cumulus_pallet_parachain_system::RelaychainDataProvider`) so + /// durations stay independent of parachain block time; tests supply + /// `frame_system::Pallet`. + type BlockNumberProvider: sp_runtime::traits::BlockNumberProvider< + BlockNumber = BlockNumberFor, + >; + /// Weight information for extrinsics in this pallet. type WeightInfo: WeightInfo; } @@ -313,6 +409,14 @@ pub mod pallet { pub type NextChallengeIndex = StorageMap<_, Blake2_128Concat, BlockNumberFor, u16, ValueQuery>; + /// Highest deadline key (relay chain block) the `on_initialize` slash + /// sweep has already drained. The sweep covers the range from here up to + /// (but excluding) the previous block's relay parent, because relay block + /// numbers can advance by more than one between consecutive parachain + /// blocks. `None` until the first block after genesis/upgrade anchors it. + #[pallet::storage] + pub type LastSweptChallengeBlock = StorageValue<_, BlockNumberFor, OptionQuery>; + /// Number of unresolved challenges currently outstanding against a /// provider, summed across every bucket. Incremented in `create_challenge` /// and decremented exactly once per resolution (defended/invalid-response @@ -1146,7 +1250,7 @@ pub mod pallet { #[pallet::weight(T::WeightInfo::deregister_provider())] pub fn deregister_provider(origin: OriginFor) -> DispatchResult { let who = ensure_signed(origin)?; - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); let complete_after = current_block.saturating_add(T::DeregisterAnnouncementPeriod::get()); @@ -1199,7 +1303,7 @@ pub mod pallet { let deregister_at = provider .deregister_at .ok_or(Error::::DeregisterNotAnnounced)?; - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); ensure!( current_block >= deregister_at, Error::::DeregisterPeriodNotElapsed @@ -1355,7 +1459,7 @@ pub mod pallet { Error::::ProviderNotFound ); - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); StorageAgreements::::try_mutate( bucket_id, @@ -1683,7 +1787,7 @@ pub mod pallet { Error::::AgreementHasPendingChallenge ); - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); let is_early_termination = current_block < agreement.expires_at; @@ -1738,7 +1842,7 @@ pub mod pallet { Error::::AgreementHasPendingChallenge ); - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); ensure!( current_block > agreement.expires_at, @@ -1786,7 +1890,7 @@ pub mod pallet { ensure!(agreement.owner == who, Error::::NotAgreementOwner); - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); let remaining_duration = if current_block < agreement.expires_at { agreement.expires_at.saturating_sub(current_block) } else { @@ -1890,7 +1994,7 @@ pub mod pallet { // Validate duration Self::validate_duration(&provider_info.settings, additional_duration)?; - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); // Check if price increased let price_increased = @@ -2064,7 +2168,7 @@ pub mod pallet { Error::::InsufficientSignatures ); - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); // Update historical roots Self::update_historical_roots(bucket, current_block, commitment.mmr_root); @@ -2208,7 +2312,7 @@ pub mod pallet { ensure!(config.enabled, Error::::ProviderCheckpointsDisabled); // Get current block and calculate current window - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); let current_window = Self::calculate_window(current_block, config.interval); // Validate window @@ -2405,7 +2509,7 @@ pub mod pallet { ensure!(config.enabled, Error::::ProviderCheckpointsDisabled); // Get current window - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); let current_window = Self::calculate_window(current_block, config.interval); // Can only report past windows @@ -2572,7 +2676,7 @@ pub mod pallet { let agreement = StorageAgreements::::get(bucket_id, &provider) .ok_or(Error::::AgreementNotFound)?; ensure!( - frame_system::Pallet::::block_number() < agreement.expires_at, + Self::current_block() < agreement.expires_at, Error::::AgreementExpired ); @@ -2633,7 +2737,7 @@ pub mod pallet { let agreement = StorageAgreements::::get(bucket_id, &provider) .ok_or(Error::::AgreementNotFound)?; ensure!( - frame_system::Pallet::::block_number() < agreement.expires_at, + Self::current_block() < agreement.expires_at, Error::::AgreementExpired ); @@ -2682,7 +2786,7 @@ pub mod pallet { // Challengeability tracks genuine obligation: only while the // agreement is live (not into the settlement window). ensure!( - frame_system::Pallet::::block_number() < agreement.expires_at, + Self::current_block() < agreement.expires_at, Error::::AgreementExpired ); @@ -2738,7 +2842,7 @@ pub mod pallet { ensure!(challenge.provider == who, Error::::NotChallengeProvider); - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); ensure!( current_block <= challenge_id.deadline, Error::::ChallengeExpired @@ -2977,7 +3081,7 @@ pub mod pallet { ProviderRole::Primary => return Err(Error::::NotReplica.into()), }; - let current_block = frame_system::Pallet::::block_number(); + let current_block = Self::current_block(); // Check sync interval if let Some(record) = last_sync { diff --git a/pallet/src/mock.rs b/pallet/src/mock.rs index 5362865b..0772d2b3 100644 --- a/pallet/src/mock.rs +++ b/pallet/src/mock.rs @@ -103,6 +103,7 @@ impl pallet_storage_provider::Config for Test { // Small cap so the cap-enforcement test can hit it without creating // thousands of challenges. type MaxChallengesPerDeadline = ConstU16<5>; + type BlockNumberProvider = System; type WeightInfo = (); } @@ -184,12 +185,10 @@ pub fn new_test_ext_with_genesis_providers( pub fn run_to_block(n: u64) { while System::block_number() < n { let current = System::block_number(); - if current > 1 { - >::on_finalize(current); - } >::on_finalize(current); System::set_block_number(current + 1); >::on_initialize(current + 1); + >::on_initialize(current + 1); } } diff --git a/pallet/src/tests/challenge.rs b/pallet/src/tests/challenge.rs index 09f582bb..42f4df36 100644 --- a/pallet/src/tests/challenge.rs +++ b/pallet/src/tests/challenge.rs @@ -1031,10 +1031,10 @@ fn remove_slashed_reindexes_snapshot_bitfield() { } /// The per-deadline challenge count is capped by `MaxChallengesPerDeadline` -/// (5 in this mock) so the `on_finalize` slash sweep stays bounded. All -/// challenges created in the same block share a deadline, so once the cap is -/// reached in block 1 the next `challenge_checkpoint` for that deadline must be -/// rejected with `TooManyChallengesThisBlock`. +/// (5 in this mock) so the `on_initialize` slash sweep stays bounded. All +/// challenges created at the same clock reading share a deadline, so once the +/// cap is reached in block 1 the next `challenge_checkpoint` for that deadline +/// must be rejected with `TooManyChallengesThisBlock`. #[test] fn challenge_count_per_deadline_is_capped() { new_test_ext().execute_with(|| { @@ -1721,6 +1721,216 @@ mod challenge_tests { }); } + /// The sweep drains a range of deadline keys, so a deadline skipped over + /// by a block-number jump (relay numbers can advance by more than one + /// between parachain blocks) is still slashed. + #[test] + fn challenge_timeout_sweep_catches_skipped_deadlines() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let (mmr_root, _, _) = single_chunk_proof(b"chunk-0"); + setup_primary_with_snapshot(mmr_root, 0, 1); + assert_ok!(StorageProvider::challenge_checkpoint( + RuntimeOrigin::signed(3), + 0, + 2, + ChunkLocation { + leaf_index: 0, + chunk_index: 0, + }, + )); + + // Walk close to the deadline (101), then jump well past it in a + // single step and run one on_initialize. + advance_to(100); + assert_eq!( + Providers::::get(2).unwrap().stats.challenges_failed, + 0 + ); + System::set_block_number(110); + >::on_initialize(110); + + // Deadline 101 was never the "current" block, yet the range sweep + // (cursor 99 → 109) drained and slashed it. + let provider = Providers::::get(2).unwrap(); + assert_eq!(provider.stats.challenges_failed, 1); + assert!(Challenges::::get(101, 0).is_none()); + assert_eq!(LastSweptChallengeBlock::::get(), Some(109)); + }); + } + + /// A single sweep drains at most `MAX_SWEEP_SPAN` (32) keys; the + /// remainder carries over via the cursor and drains on later blocks, so + /// a huge gap cannot blow one block but slashes still land. + #[test] + fn challenge_timeout_sweep_is_capped_and_carries_over() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let (mmr_root, _, _) = single_chunk_proof(b"chunk-0"); + setup_primary_with_snapshot(mmr_root, 0, 1); + assert_ok!(StorageProvider::challenge_checkpoint( + RuntimeOrigin::signed(3), + 0, + 2, + ChunkLocation { + leaf_index: 0, + chunk_index: 0, + }, + )); + + // Anchor the cursor at 1, then jump far past the deadline (101). + advance_to(2); + assert_eq!(LastSweptChallengeBlock::::get(), Some(1)); + System::set_block_number(500); + + // Each sweep advances the cursor by at most 32 keys: 33, 65, 97 — + // deadline 101 still pending after three sweeps. + for expected_cursor in [33, 65, 97] { + >::on_initialize(500); + assert_eq!( + LastSweptChallengeBlock::::get(), + Some(expected_cursor) + ); + } + assert!(Challenges::::get(101, 0).is_some()); + assert_eq!( + Providers::::get(2).unwrap().stats.challenges_failed, + 0 + ); + + // Fourth sweep covers 98..=129 and slashes. + >::on_initialize(500); + assert_eq!(LastSweptChallengeBlock::::get(), Some(129)); + assert!(Challenges::::get(101, 0).is_none()); + assert_eq!( + Providers::::get(2).unwrap().stats.challenges_failed, + 1 + ); + }); + } + + /// The sweep never slashes more than `MaxChallengesPerDeadline` (5 in + /// this mock) challenges per block even when a gap matured several + /// populated deadlines at once; on exhaustion it parks the cursor below + /// the partially drained key and finishes on the next block. + #[test] + fn challenge_timeout_sweep_budget_carries_over_mid_key() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let (mmr_root, _, _) = single_chunk_proof(b"chunk-0"); + setup_primary_with_snapshot(mmr_root, 0, 1); + + let challenge = || { + assert_ok!(StorageProvider::challenge_checkpoint( + RuntimeOrigin::signed(3), + 0, + 2, + ChunkLocation { + leaf_index: 0, + chunk_index: 0, + }, + )); + }; + + // Load two consecutive deadlines with 3 challenges each: 199 + // (created at block 99) and 200 (created at block 100). + run_to_block(99); + for _ in 0..3 { + challenge(); + } + run_to_block(100); + for _ in 0..3 { + challenge(); + } + assert_eq!(PendingChallenges::::get(2), 6); + + // Jump far past both deadlines. The span cap (32 keys/sweep) + // takes three sweeps to walk the empty range up to key 195. + System::set_block_number(300); + for _ in 0..3 { + >::on_initialize(300); + } + assert_eq!(LastSweptChallengeBlock::::get(), Some(195)); + assert_eq!( + Providers::::get(2).unwrap().stats.challenges_failed, + 0 + ); + + // Fourth sweep reaches the loaded keys: drains all 3 at 199, + // then only 2 of 3 at 200 before the budget (5) is exhausted — + // cursor parks below the partially drained key, whose index + // allocator survives for the carry-over. + >::on_initialize(300); + assert_eq!( + Providers::::get(2).unwrap().stats.challenges_failed, + 5 + ); + assert_eq!(LastSweptChallengeBlock::::get(), Some(199)); + assert_eq!(Challenges::::iter_prefix(200).count(), 1); + assert_eq!(NextChallengeIndex::::get(200), 3); + assert_eq!(PendingChallenges::::get(2), 1); + + // Fifth sweep finishes the key and cleans up its allocator. + >::on_initialize(300); + assert_eq!( + Providers::::get(2).unwrap().stats.challenges_failed, + 6 + ); + assert_eq!(Challenges::::iter_prefix(200).count(), 0); + assert_eq!(NextChallengeIndex::::get(200), 0); + assert_eq!(PendingChallenges::::get(2), 0); + assert_eq!(LastSweptChallengeBlock::::get(), Some(231)); + }); + } + + /// Re-running the sweep at an unchanged block number (several parachain + /// blocks can share one relay parent) is a no-op: no double slash, no + /// counter underflow. + #[test] + fn challenge_timeout_sweep_idempotent_at_same_block() { + new_test_ext().execute_with(|| { + System::set_block_number(1); + let (mmr_root, _, _) = single_chunk_proof(b"chunk-0"); + setup_primary_with_snapshot(mmr_root, 0, 1); + assert_ok!(StorageProvider::challenge_checkpoint( + RuntimeOrigin::signed(3), + 0, + 2, + ChunkLocation { + leaf_index: 0, + chunk_index: 0, + }, + )); + + advance_to(102); + let provider = Providers::::get(2).unwrap(); + assert_eq!(provider.stats.challenges_failed, 1); + assert_eq!(PendingChallenges::::get(2), 0); + let cursor = LastSweptChallengeBlock::::get(); + + >::on_initialize(102); + + let provider = Providers::::get(2).unwrap(); + assert_eq!(provider.stats.challenges_failed, 1); + assert_eq!(PendingChallenges::::get(2), 0); + assert_eq!(LastSweptChallengeBlock::::get(), cursor); + }); + } + + /// The very first sweep anchors the cursor at the current clock instead + /// of scanning up from zero (live relay numbers start in the millions). + #[test] + fn challenge_timeout_sweep_anchors_cursor_on_first_run() { + new_test_ext().execute_with(|| { + System::set_block_number(1_000_000); + assert_eq!(LastSweptChallengeBlock::::get(), None); + + >::on_initialize(1_000_000); + + assert_eq!(LastSweptChallengeBlock::::get(), Some(999_999)); + }); + } + // ───────────────────────────────────────────────────────────────────────── // challenge_replica // ───────────────────────────────────────────────────────────────────────── diff --git a/runtimes/web3-storage-local/src/constants.rs b/runtimes/web3-storage-local/src/constants.rs index f1a21c3b..b1399546 100644 --- a/runtimes/web3-storage-local/src/constants.rs +++ b/runtimes/web3-storage-local/src/constants.rs @@ -49,6 +49,18 @@ pub mod time { pub const HOURS: BlockNumber = MINUTES * 60; } +/// Durations measured in RELAY chain blocks (6s), independent of the +/// parachain block time. All storage-pallet timeouts are denominated in relay +/// blocks (via `pallet_storage_provider::Config::BlockNumberProvider`) so +/// they keep their wall-clock meaning when the parachain block time changes. +pub mod relay_time { + use crate::BlockNumber; + + pub const MINUTES: BlockNumber = + 60_000 / (super::consensus::RELAY_CHAIN_SLOT_DURATION_MILLIS as BlockNumber); + pub const HOURS: BlockNumber = MINUTES * 60; +} + /// Constants relating to the native token. pub mod currency { use crate::Balance; diff --git a/runtimes/web3-storage-local/src/storage.rs b/runtimes/web3-storage-local/src/storage.rs index 4e8ca49b..1d1f2341 100644 --- a/runtimes/web3-storage-local/src/storage.rs +++ b/runtimes/web3-storage-local/src/storage.rs @@ -10,10 +10,14 @@ use frame_support::{ use sp_runtime::traits::AccountIdConversion; use crate::{ - constants::{currency::UNIT, time::HOURS}, + constants::{currency::UNIT, relay_time::HOURS}, AccountId, Balance, Balances, BlockNumber, Runtime, RuntimeEvent, }; +// Every duration below is measured in RELAY chain blocks (6s), not parachain +// blocks: the storage pallet reads its clock from +// `cumulus_pallet_parachain_system::RelaychainDataProvider`, so these keep +// their wall-clock meaning when the parachain block time changes. parameter_types! { pub const MinProviderStake: Balance = 1_000 * UNIT; // 1000 tokens minimum stake pub const ChallengeTimeout: BlockNumber = 48 * HOURS; // 48 hours to respond @@ -30,8 +34,8 @@ parameter_types! { pub const RequestTimeout: BlockNumber = 6 * HOURS; // 1 token (1e12) per 1 GB (1e9 bytes) = 1000 per byte pub const MinStakePerByte: Balance = 1_000; - pub const DefaultCheckpointInterval: BlockNumber = 100; - pub const DefaultCheckpointGrace: BlockNumber = 20; + pub const DefaultCheckpointInterval: BlockNumber = 100; // relay blocks (~10 min) + pub const DefaultCheckpointGrace: BlockNumber = 20; // relay blocks (~2 min) pub const CheckpointReward: Balance = 1_000_000_000_000; // 1 token pub const CheckpointMissPenalty: Balance = 500_000_000_000; // 0.5 token /// Must be `> ChallengeTimeout` so any challenge opened up to the @@ -41,9 +45,10 @@ parameter_types! { /// re-register replay defense). Both are checked in `integrity_test`. /// Value: the 48h challenge window plus a 6h grace. pub const DeregisterAnnouncementPeriod: BlockNumber = 54 * HOURS; - /// Caps the challenges sharing one deadline block so the `on_finalize` - /// slash sweep stays bounded. Generous: only challenges created in the - /// same block share a deadline. + /// Caps the challenges sharing one deadline (relay block) and the + /// `on_initialize` sweep's per-block slash budget. Generous: only + /// challenges created while the chain sits on the same relay parent + /// share a deadline. pub const MaxChallengesPerDeadline: u16 = 1_000; } @@ -104,5 +109,6 @@ impl pallet_storage_provider::Config for Runtime { type MaxBucketsPerMember = ConstU32<1000>; type DeregisterAnnouncementPeriod = DeregisterAnnouncementPeriod; type MaxChallengesPerDeadline = MaxChallengesPerDeadline; + type BlockNumberProvider = cumulus_pallet_parachain_system::RelaychainDataProvider; type WeightInfo = crate::weights::pallet_storage_provider::WeightInfo; } diff --git a/runtimes/web3-storage-paseo/src/paseo_constants.rs b/runtimes/web3-storage-paseo/src/paseo_constants.rs index 9bdbccda..50bf1db7 100644 --- a/runtimes/web3-storage-paseo/src/paseo_constants.rs +++ b/runtimes/web3-storage-paseo/src/paseo_constants.rs @@ -54,6 +54,18 @@ pub mod time { pub const HOURS: BlockNumber = MINUTES * 60; } +/// Durations measured in RELAY chain blocks (6s), independent of the +/// parachain block time. All storage-pallet timeouts are denominated in relay +/// blocks (via `pallet_storage_provider::Config::BlockNumberProvider`) so +/// they keep their wall-clock meaning when the parachain block time changes. +pub mod relay_time { + use crate::BlockNumber; + + pub const MINUTES: BlockNumber = + 60_000 / (super::consensus::RELAY_CHAIN_SLOT_DURATION_MILLIS as BlockNumber); + pub const HOURS: BlockNumber = MINUTES * 60; +} + /// Constants relating to the native token. pub mod currency { use crate::Balance; diff --git a/runtimes/web3-storage-paseo/src/storage.rs b/runtimes/web3-storage-paseo/src/storage.rs index eb12643b..ff8a1414 100644 --- a/runtimes/web3-storage-paseo/src/storage.rs +++ b/runtimes/web3-storage-paseo/src/storage.rs @@ -11,7 +11,7 @@ use frame_support::{ use sp_runtime::traits::AccountIdConversion; use crate::{ - paseo_constants::{currency::UNIT, time::HOURS}, + paseo_constants::{currency::UNIT, relay_time::HOURS}, AccountId, Balance, Balances, BlockNumber, Runtime, RuntimeEvent, }; @@ -20,6 +20,11 @@ use crate::{ // a runtime upgrade. Useful for tuning previewnet timing without redeploying the // wasm. Each `pub storage X` exposes `X::key()` / `X::set()` and reads the // current value from unhashed storage, falling back to the default. +// +// Every duration below is measured in RELAY chain blocks (6s), not parachain +// blocks: the storage pallet reads its clock from +// `cumulus_pallet_parachain_system::RelaychainDataProvider`, so these keep +// their wall-clock meaning when the parachain block time changes. parameter_types! { pub storage MinProviderStake: Balance = 1_000 * UNIT; // 1000 tokens minimum stake pub storage ChallengeTimeout: BlockNumber = 48 * HOURS; @@ -36,8 +41,8 @@ parameter_types! { pub storage RequestTimeout: BlockNumber = 6 * HOURS; // 1 token (1e12) per 1 GB (1e9 bytes) = 1000 per byte pub storage MinStakePerByte: Balance = 1_000; - pub storage DefaultCheckpointInterval: BlockNumber = 100; - pub storage DefaultCheckpointGrace: BlockNumber = 20; + pub storage DefaultCheckpointInterval: BlockNumber = 100; // relay blocks (~10 min) + pub storage DefaultCheckpointGrace: BlockNumber = 20; // relay blocks (~2 min) pub storage CheckpointReward: Balance = 1_000_000_000_000; // 1 token pub storage CheckpointMissPenalty: Balance = 500_000_000_000; // 0.5 token /// Must be `> ChallengeTimeout` so any challenge opened up to the @@ -47,9 +52,10 @@ parameter_types! { /// re-register replay defense). Both are checked in `integrity_test`. /// Value: the 48h challenge window plus a 6h grace. pub storage DeregisterAnnouncementPeriod: BlockNumber = 54 * HOURS; - /// Caps the challenges sharing one deadline block so the `on_finalize` - /// slash sweep stays bounded. Generous: only challenges created in the - /// same block share a deadline. + /// Caps the challenges sharing one deadline (relay block) and the + /// `on_initialize` sweep's per-block slash budget. Generous: only + /// challenges created while the chain sits on the same relay parent + /// share a deadline. pub storage MaxChallengesPerDeadline: u16 = 1_000; } @@ -110,5 +116,6 @@ impl pallet_storage_provider::Config for Runtime { type MaxBucketsPerMember = ConstU32<1000>; type DeregisterAnnouncementPeriod = DeregisterAnnouncementPeriod; type MaxChallengesPerDeadline = MaxChallengesPerDeadline; + type BlockNumberProvider = cumulus_pallet_parachain_system::RelaychainDataProvider; type WeightInfo = crate::weights::pallet_storage_provider::WeightInfo; } diff --git a/runtimes/web3-storage-paseo/tests/tests.rs b/runtimes/web3-storage-paseo/tests/tests.rs index 3af8a828..d3efc5df 100644 --- a/runtimes/web3-storage-paseo/tests/tests.rs +++ b/runtimes/web3-storage-paseo/tests/tests.rs @@ -15,9 +15,9 @@ use sp_keyring::Sr25519Keyring; use sp_runtime::{transaction_validity, ApplyExtrinsicResult, BuildStorage}; use storage_paseo_runtime::{ paseo_constants::currency::UNIT, xcm_config::LocationToAccountId, AllPalletsWithoutSystem, - Balance, Balances, Block, Runtime, RuntimeCall, RuntimeEvent, RuntimeGenesisConfig, - RuntimeOrigin, S3Registry, SessionKeys, StorageProvider, System, TxExtension, - UncheckedExtrinsic, WeightToFee, + Balance, Balances, Block, BlockNumber, Runtime, RuntimeCall, RuntimeEvent, + RuntimeGenesisConfig, RuntimeOrigin, S3Registry, SessionKeys, StorageProvider, System, + TxExtension, UncheckedExtrinsic, WeightToFee, }; use storage_primitives::AgreementTerms; use xcm::latest::prelude::*; @@ -29,6 +29,15 @@ use xcm_runtime_apis::conversions::LocationToAccountHelper; const ALICE: [u8; 32] = [1u8; 32]; +/// Set both clocks the runtime reads: `System` (parachain height) and the +/// relay-chain number served by `RelaychainDataProvider`, which the storage +/// pallets measure all durations against. Kept in lockstep for simplicity. +fn set_clocks(n: BlockNumber) { + use sp_runtime::traits::BlockNumberProvider; + System::set_block_number(n); + cumulus_pallet_parachain_system::RelaychainDataProvider::::set_block_number(n); +} + /// Advance to the next block for testing transaction storage. fn advance_block() { let current = frame_system::Pallet::::block_number(); @@ -37,12 +46,13 @@ fn advance_block() { >::on_finalize(current); let next = current + 1; - System::set_block_number(next); + set_clocks(next); frame_system::BlockWeight::::kill(); frame_system::BlockSize::::kill(); >::on_initialize(next); + >::on_initialize(next); } fn construct_extrinsic( @@ -155,7 +165,9 @@ fn primary_terms( max_bytes, duration, price_per_byte: 0, - valid_until: frame_system::Pallet::::block_number() + // `valid_until` is checked against the pallet's relay-chain clock, + // not the parachain height. + valid_until: pallet_storage_provider::Pallet::::current_block() + ::RequestTimeout::get(), nonce, bucket_id: None, @@ -494,11 +506,11 @@ fn should_deregister_provider() { ); // Step 3: fast-forward through the announcement period. Jump close to - // the boundary with `set_block_number` (the period is `48 * HOURS` + // the boundary with `set_clocks` (the period is `48 * HOURS` // ≈ 28,800 blocks, far too many to iterate one-by-one) then cross it // via `advance_block` so the `on_finalize` / `on_initialize` hooks // fire at the maturing block. - System::set_block_number(deregister_at.saturating_sub(1)); + set_clocks(deregister_at.saturating_sub(1)); advance_block(); assert!(frame_system::Pallet::::block_number() >= deregister_at); diff --git a/storage-interfaces/file-system/pallet-registry/src/bechmarking.rs b/storage-interfaces/file-system/pallet-registry/src/bechmarking.rs index 3b1d8107..81a41b52 100644 --- a/storage-interfaces/file-system/pallet-registry/src/bechmarking.rs +++ b/storage-interfaces/file-system/pallet-registry/src/bechmarking.rs @@ -107,7 +107,7 @@ fn make_primary_terms(owner: &T::AccountId, nonce: u64) -> AgreementT max_bytes: 1_000u64, duration: 100u32.into(), price_per_byte: 1u32.into(), - valid_until: frame_system::Pallet::::block_number() + valid_until: pallet_storage_provider::Pallet::::current_block() .saturating_add(::RequestTimeout::get()), nonce, bucket_id: None, diff --git a/storage-interfaces/file-system/pallet-registry/src/lib.rs b/storage-interfaces/file-system/pallet-registry/src/lib.rs index b3e0944c..d9400b87 100644 --- a/storage-interfaces/file-system/pallet-registry/src/lib.rs +++ b/storage-interfaces/file-system/pallet-registry/src/lib.rs @@ -237,7 +237,7 @@ pub mod pallet { let next_id = drive_id.checked_add(1).ok_or(Error::::DriveIdOverflow)?; // Calculate expiry block - let current_block = >::block_number(); + let current_block = pallet_storage_provider::Pallet::::current_block(); let expires_at = current_block + storage_period; // Create drive info diff --git a/storage-interfaces/file-system/pallet-registry/src/mock.rs b/storage-interfaces/file-system/pallet-registry/src/mock.rs index 7552252a..e200ae5d 100644 --- a/storage-interfaces/file-system/pallet-registry/src/mock.rs +++ b/storage-interfaces/file-system/pallet-registry/src/mock.rs @@ -115,6 +115,7 @@ impl pallet_storage_provider::Config for Test { // pallet's `integrity_test`. 100 + 50 grace. type DeregisterAnnouncementPeriod = ConstU64<150>; type MaxChallengesPerDeadline = ConstU16<1_000>; + type BlockNumberProvider = System; type WeightInfo = (); } diff --git a/storage-interfaces/s3/pallet-s3-registry/src/benchmarking.rs b/storage-interfaces/s3/pallet-s3-registry/src/benchmarking.rs index 62767cad..2c890862 100644 --- a/storage-interfaces/s3/pallet-s3-registry/src/benchmarking.rs +++ b/storage-interfaces/s3/pallet-s3-registry/src/benchmarking.rs @@ -182,7 +182,7 @@ fn insert_s3_bucket( name: bounded_name.clone(), layer0_bucket_id: 0, owner: owner.clone(), - created_at: frame_system::Pallet::::block_number(), + created_at: pallet_storage_provider::Pallet::::current_block(), object_count, total_size: 0, }; @@ -230,7 +230,7 @@ mod benchmarks { max_bytes, duration, price_per_byte: 1u32.into(), - valid_until: frame_system::Pallet::::block_number() + valid_until: pallet_storage_provider::Pallet::::current_block() .saturating_add(::RequestTimeout::get()), nonce: 1, bucket_id: None, diff --git a/storage-interfaces/s3/pallet-s3-registry/src/lib.rs b/storage-interfaces/s3/pallet-s3-registry/src/lib.rs index 85e84e01..d3dc0426 100644 --- a/storage-interfaces/s3/pallet-s3-registry/src/lib.rs +++ b/storage-interfaces/s3/pallet-s3-registry/src/lib.rs @@ -228,7 +228,7 @@ pub mod pallet { name: bounded_name.clone(), layer0_bucket_id, owner: who.clone(), - created_at: frame_system::Pallet::::block_number(), + created_at: pallet_storage_provider::Pallet::::current_block(), object_count: 0, total_size: 0, }; @@ -333,7 +333,8 @@ pub mod pallet { .unwrap_or_default(); // Get current timestamp - let timestamp = frame_system::Pallet::::block_number().saturated_into::(); + let timestamp = + pallet_storage_provider::Pallet::::current_block().saturated_into::(); // Check if this is an update or new object match Objects::::get(s3_bucket_id, &bounded_key) { @@ -450,7 +451,7 @@ pub mod pallet { // Update last modified metadata.last_modified = - frame_system::Pallet::::block_number().saturated_into::(); + pallet_storage_provider::Pallet::::current_block().saturated_into::(); // Update destination bucket stats (re-read if same bucket since src was read separately) let mut dst_bucket = diff --git a/storage-interfaces/s3/pallet-s3-registry/src/mock.rs b/storage-interfaces/s3/pallet-s3-registry/src/mock.rs index 9befcdec..c7b233d7 100644 --- a/storage-interfaces/s3/pallet-s3-registry/src/mock.rs +++ b/storage-interfaces/s3/pallet-s3-registry/src/mock.rs @@ -115,6 +115,7 @@ impl pallet_storage_provider::Config for Test { // pallet's `integrity_test`. 100 + 50 grace. type DeregisterAnnouncementPeriod = ConstU64<150>; type MaxChallengesPerDeadline = ConstU16<1_000>; + type BlockNumberProvider = System; type WeightInfo = (); } From df9390c261fe37fa5cebad636880e5867e260ef2 Mon Sep 17 00:00:00 2001 From: Ilia Churin Date: Tue, 7 Jul 2026 13:52:41 +0900 Subject: [PATCH 02/20] Source the relay chain block number in off-chain consumers The pallet now measures every duration against the relay chain clock, so off-chain actors must snapshot ParachainSystem.LastRelayChainBlockNumber instead of the parachain height wherever a value meets an on-chain check. Provider node: chain_state.current_block now tracks the relay block anchored to each finalized parachain block (one extra storage read per block), feeding /negotiate's valid_until and the checkpoint window math; the fetch/decode is shared via a new storage_client::substrate::fetch_last_relay_block_number helper. JS SDK: new currentRelayBlock/waitForRelayBlock helpers in @web3-storage/layer0. Demos and e2e suites switch their CommitmentPayload nonce snapshots off System.Number (which would be rejected on any network where relay numbers dwarf parachain heights) and wait for agreement expiry / checkpoint windows on the relay clock. full-flow's demo agreement duration grows 15 -> 40 relay blocks since the relay clock keeps ticking while the parachain onboards. --- client/src/substrate.rs | 34 ++++++++++++++ examples/papi/checkpoint-missed.ts | 7 +-- examples/papi/e2e/02-agreement-lifecycle.ts | 6 +-- .../papi/e2e/04-data-upload-and-retrieval.ts | 15 +++--- .../papi/e2e/05-checkpoint-and-challenges.ts | 13 ++--- .../papi/e2e/10-edge-cases-and-adversarial.ts | 15 +++--- examples/papi/full-flow.ts | 14 ++++-- examples/papi/sc-coverage.ts | 5 +- examples/papi/sc-flow.ts | 3 +- packages/layer0/src/provider-http.ts | 12 +++-- packages/layer0/src/waits.ts | 47 ++++++++++++++++++- provider-node/src/chain_state_coordinator.rs | 25 ++++++++-- provider-node/src/subxt_client.rs | 15 ++++-- provider-node/src/types.rs | 2 +- 14 files changed, 162 insertions(+), 51 deletions(-) diff --git a/client/src/substrate.rs b/client/src/substrate.rs index b6c47e7b..c1afca43 100644 --- a/client/src/substrate.rs +++ b/client/src/substrate.rs @@ -897,6 +897,40 @@ pub mod storage { vec![subxt::dynamic::Value::from_bytes(account.as_ref() as &[u8])], ) } + + /// Query `ParachainSystem::LastRelayChainBlockNumber` — the relay-chain + /// block anchored to the queried parachain block. This is the clock every + /// storage-pallet duration (timeouts, expiries, `valid_until`, nonces) is + /// measured against, so off-chain actors must read it, not the parachain + /// block height. + pub fn last_relay_block_number() -> subxt::storage::DefaultAddress< + Vec, + subxt::dynamic::DecodedValueThunk, + subxt::utils::Yes, + subxt::utils::Yes, + subxt::utils::Yes, + > { + subxt::dynamic::storage("ParachainSystem", "LastRelayChainBlockNumber", vec![]) + } +} + +/// Fetch and decode `ParachainSystem::LastRelayChainBlockNumber` from a +/// storage view (`api.storage().at_latest()` or `block.storage()`). +pub async fn fetch_last_relay_block_number( + storage: &subxt::storage::Storage>, +) -> Result { + let thunk = storage + .fetch(&storage::last_relay_block_number()) + .await + .map_err(|e| ClientError::Chain(format!("Failed to fetch relay block number: {e}")))? + .ok_or_else(|| ClientError::Chain("LastRelayChainBlockNumber not found".to_string()))?; + let value = thunk + .to_value() + .map_err(|e| ClientError::Chain(format!("Failed to decode relay block number: {e}")))?; + value + .as_u128() + .map(|v| v as u32) + .ok_or_else(|| ClientError::Chain("relay block number is not an integer".to_string())) } // Helper functions for common operations diff --git a/examples/papi/checkpoint-missed.ts b/examples/papi/checkpoint-missed.ts index c4e3139b..59f2778e 100644 --- a/examples/papi/checkpoint-missed.ts +++ b/examples/papi/checkpoint-missed.ts @@ -26,6 +26,7 @@ import assert from "node:assert"; import { configureCheckpointWindow, connect, + currentRelayBlock, ensureProviderRegistered, establishStorageAgreement, makeSigner, @@ -33,7 +34,7 @@ import { READ_OPTS, reportMissedCheckpoint, sameAddress, - waitForBlock, + waitForRelayBlock, waitForBlockProduction, waitForChainReady, waitForNextBlock, @@ -107,7 +108,7 @@ async function main() { ); console.log("\n=== Step 3: Pick a window and let it elapse without a checkpoint ==="); - const head = Number(await api.query.System.Number.getValue(READ_OPTS)); + const head = await currentRelayBlock(api); const missedWindow = BigInt(Math.floor(head / WINDOW_INTERVAL)); // window_end = (missedWindow + 1) * interval ; need current_block > window_end const windowEnd = (Number(missedWindow) + 1) * WINDOW_INTERVAL; @@ -118,7 +119,7 @@ async function main() { windowEnd, windowEnd ); - await waitForBlock(papi, windowEnd); + await waitForRelayBlock(papi, api, windowEnd); console.log("\n=== Step 4: Record balances, then report_missed_checkpoint ==="); const providerBefore = (await api.query.StorageProvider.Providers.getValue( diff --git a/examples/papi/e2e/02-agreement-lifecycle.ts b/examples/papi/e2e/02-agreement-lifecycle.ts index b7c37fcc..f96c7ae4 100644 --- a/examples/papi/e2e/02-agreement-lifecycle.ts +++ b/examples/papi/e2e/02-agreement-lifecycle.ts @@ -24,7 +24,7 @@ import { negotiateTerms, READ_OPTS, sameAddress, - waitForBlock, + waitForRelayBlock, } from "@web3-storage/sdk"; import { getFree, @@ -90,7 +90,7 @@ async function main() { ))!; const expiresAt = Number(agreement.expires_at); console.log(" Waiting for expiry at block %d...", expiresAt); - await waitForBlock(papi, expiresAt); + await waitForRelayBlock(papi, api, expiresAt); const provBefore = await getFree(api, provider); await endAgreement(api, client, provider, bucketId, "Pay"); const provAfter = await getFree(api, provider); @@ -117,7 +117,7 @@ async function main() { READ_OPTS ))!; console.log(" Waiting for expiry at block %d...", Number(agreement.expires_at)); - await waitForBlock(papi, Number(agreement.expires_at)); + await waitForRelayBlock(papi, api, Number(agreement.expires_at)); const result = await endAgreement(api, client, provider, bucketId, "Burn", { burn_percent: 100, }); diff --git a/examples/papi/e2e/04-data-upload-and-retrieval.ts b/examples/papi/e2e/04-data-upload-and-retrieval.ts index 7708474b..cc863782 100644 --- a/examples/papi/e2e/04-data-upload-and-retrieval.ts +++ b/examples/papi/e2e/04-data-upload-and-retrieval.ts @@ -13,6 +13,7 @@ import assert from "node:assert"; import { blake2b256 } from "@polkadot-labs/hdkd-helpers"; import { + currentRelayBlock, downloadChunk, ensureProviderRegistered, makeSigner, @@ -55,7 +56,7 @@ async function main() { name: "4.1 Small chunk (100 bytes)", fn: async () => { const data = "x".repeat(100); - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const { hash, commit } = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); assert.ok(commit.mmr_root, "Should return mmr_root"); const downloaded = await downloadChunk(PROVIDER_URL, hash); @@ -67,7 +68,7 @@ async function main() { name: "4.2 Medium chunk (64 KB)", fn: async () => { const data = randomBytes(64 * 1024); - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const { hash } = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); const downloaded = await downloadChunk(PROVIDER_URL, hash); assert.deepStrictEqual(new Uint8Array(downloaded), data, "64KB roundtrip integrity"); @@ -78,7 +79,7 @@ async function main() { name: "4.3 Max chunk size (256 KB)", fn: async () => { const data = randomBytes(256 * 1024); - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const { hash } = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); const downloaded = await downloadChunk(PROVIDER_URL, hash); assert.deepStrictEqual(new Uint8Array(downloaded), data, "256KB roundtrip integrity"); @@ -89,7 +90,7 @@ async function main() { name: "4.4 Multiple sequential uploads", fn: async () => { const uploads = []; - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); for (let i = 0; i < 5; i++) { const data = `sequential upload #${i} @ ${Date.now()}`; const result = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); @@ -207,7 +208,7 @@ async function main() { name: "4.10 Upload binary (non-UTF8) data", fn: async () => { const binary = randomBytes(512); - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const { hash } = await uploadChunk(PROVIDER_URL, bucketId, binary, nonce); const downloaded = await downloadChunk(PROVIDER_URL, hash); assert.deepStrictEqual(new Uint8Array(downloaded), binary, "Binary roundtrip should match"); @@ -218,7 +219,7 @@ async function main() { name: "4.11 Upload identical content twice — different MMR leaves", fn: async () => { const data = "duplicate content for e2e"; - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const first = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); const second = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); assert.strictEqual(first.hash, second.hash, "Same data should produce same hash"); @@ -236,7 +237,7 @@ async function main() { const data = "verify hash computation"; const bytes = new TextEncoder().encode(data); const expectedHash = toHex(blake2b256(bytes)); - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const { hash } = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); assert.strictEqual(hash, expectedHash, "Provider hash should match local blake2-256"); }, diff --git a/examples/papi/e2e/05-checkpoint-and-challenges.ts b/examples/papi/e2e/05-checkpoint-and-challenges.ts index ada5fc3e..83c1e030 100644 --- a/examples/papi/e2e/05-checkpoint-and-challenges.ts +++ b/examples/papi/e2e/05-checkpoint-and-challenges.ts @@ -16,6 +16,7 @@ import { challengeOffchain, claimCheckpointRewards, configureCheckpointWindow, + currentRelayBlock, ensureProviderRegistered, fetchChallengeProof, fetchCheckpointDuty, @@ -28,7 +29,7 @@ import { submitClientCheckpoint, submitProviderCheckpoint, uploadChunk, - waitForBlock, + waitForRelayBlock, } from "@web3-storage/sdk"; import { ensureSoleAcceptingProvider } from "../support.js"; import { negotiateAndEstablish, runSuite, submitTxExpectFailure, setupChain } from "./helpers.js"; @@ -57,7 +58,7 @@ async function main() { }); const payload = `checkpoint-test @ ${Date.now()}`; - const uploadNonce = Number(await api.query.System.Number.getValue()); + const uploadNonce = await currentRelayBlock(api); const upload = await uploadChunk(PROVIDER_URL, bucketId, payload, uploadNonce); const uploadInfo = { leafIndex: upload.commit.leaf_indices[0], @@ -75,7 +76,7 @@ async function main() { tests.push({ name: "5.1 Client checkpoint", fn: async () => { - const ckNonce = Number(await api.query.System.Number.getValue()); + const ckNonce = await currentRelayBlock(api); const ck = await fetchCheckpointSignature(PROVIDER_URL, bucketId, ckNonce); assert.ok(ck.mmr_root, "Checkpoint should have mmr_root"); const result = await submitClientCheckpoint(api, client, provider, bucketId, ck); @@ -131,12 +132,12 @@ async function main() { // Compute current window with headroom. const HEADROOM = 8; - let currentBlock = Number(await api.query.System.Number.getValue(READ_OPTS)); + let currentBlock = await currentRelayBlock(api); let windowNum = Math.floor(currentBlock / WINDOW_INTERVAL); let nextWindowStart = (windowNum + 1) * WINDOW_INTERVAL; if (nextWindowStart - currentBlock < HEADROOM) { - await waitForBlock(papi, nextWindowStart - 1); - currentBlock = Number(await api.query.System.Number.getValue(READ_OPTS)); + await waitForRelayBlock(papi, api, nextWindowStart - 1); + currentBlock = await currentRelayBlock(api); windowNum = Math.floor(currentBlock / WINDOW_INTERVAL); } const window = BigInt(windowNum); diff --git a/examples/papi/e2e/10-edge-cases-and-adversarial.ts b/examples/papi/e2e/10-edge-cases-and-adversarial.ts index 45cb51c6..98045f1b 100644 --- a/examples/papi/e2e/10-edge-cases-and-adversarial.ts +++ b/examples/papi/e2e/10-edge-cases-and-adversarial.ts @@ -15,6 +15,7 @@ import { Enum } from "polkadot-api"; import { blake2b256 } from "@polkadot-labs/hdkd-helpers"; import { endAgreement, + currentRelayBlock, ensureProviderRegistered, fetchCheckpointSignature, freezeBucket, @@ -24,7 +25,7 @@ import { submitClientCheckpoint, toHex, uploadChunk, - waitForBlock, + waitForRelayBlock, } from "@web3-storage/sdk"; import { ensureSoleAcceptingProvider } from "../support.js"; import { @@ -108,7 +109,7 @@ async function main() { provider.address, READ_OPTS ))!; - await waitForBlock(papi, Number(agreement.expires_at)); + await waitForRelayBlock(papi, api, Number(agreement.expires_at)); await endAgreement(api, bob, provider, bucketId, "Pay"); const infoAfter = (await api.query.StorageProvider.Providers.getValue( provider.address, @@ -131,7 +132,7 @@ async function main() { duration: 100, }); // freeze_bucket requires a snapshot (checkpoint) to exist. - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); await uploadChunk(PROVIDER_URL, bucketId, "data for snapshot", nonce); const ck = await fetchCheckpointSignature(PROVIDER_URL, bucketId, nonce); await submitClientCheckpoint(api, bob, provider, bucketId, ck); @@ -153,7 +154,7 @@ async function main() { duration: 100, }); // Upload some data. - const nonce1 = Number(await api.query.System.Number.getValue()); + const nonce1 = await currentRelayBlock(api); await uploadChunk(PROVIDER_URL, bucketId, "pre-freeze data", nonce1); // Checkpoint before freeze. const ck1 = await fetchCheckpointSignature(PROVIDER_URL, bucketId, nonce1); @@ -161,7 +162,7 @@ async function main() { // Freeze. await freezeBucket(api, bob, bucketId); // Upload more data. - const nonce2 = Number(await api.query.System.Number.getValue()); + const nonce2 = await currentRelayBlock(api); await uploadChunk(PROVIDER_URL, bucketId, "post-freeze data", nonce2); // Checkpoint after freeze — should still work (captures frozen_start_seq). const ck2 = await fetchCheckpointSignature(PROVIDER_URL, bucketId, nonce2); @@ -213,7 +214,7 @@ async function main() { const data = "integrity check data for blake2-256"; const bytes = new TextEncoder().encode(data); const expectedHash = toHex(blake2b256(bytes)); - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const { hash } = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); assert.strictEqual(hash, expectedHash, "Provider hash should match local blake2-256"); }, @@ -227,7 +228,7 @@ async function main() { duration: 100, }); const data = "identical content for dedup test"; - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const r1 = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); const r2 = await uploadChunk(PROVIDER_URL, bucketId, data, nonce); assert.strictEqual(r1.hash, r2.hash, "Hashes should match for identical content"); diff --git a/examples/papi/full-flow.ts b/examples/papi/full-flow.ts index b9b86673..372e8f3e 100644 --- a/examples/papi/full-flow.ts +++ b/examples/papi/full-flow.ts @@ -24,6 +24,7 @@ import { challengeCheckpoint, challengeOffchain, connect, + currentRelayBlock, downloadChunk, endAgreement, ensureProviderRegistered, @@ -37,7 +38,7 @@ import { respondToChallenge, submitClientCheckpoint, uploadChunk, - waitForBlock, + waitForRelayBlock, waitForBlockProduction, waitForChainReady, waitForNextBlock, @@ -65,7 +66,10 @@ async function setupAgreement( provider: ChainSigner ): Promise { const maxBytes = 1_073_741_824n; // 1 GiB - const duration = 15; + // Relay-chain blocks (6s each). The relay clock keeps ticking while the + // parachain onboards or skips slots, so this needs headroom for steps 2-7 + // before the step-8 expiry wait. + const duration = 40; console.log( " Negotiating signed terms with provider (max_bytes=%s, duration=%d)...", maxBytes, @@ -92,7 +96,7 @@ async function setupAgreement( async function uploadAndVerify(api: ParachainApi, bucketId: bigint) { const payload = `Hello, Web3 Storage! [${new Date().toISOString()}] provider=${PROVIDER_SEED}`; - const nonce = Number(await api.query.System.Number.getValue()); + const nonce = await currentRelayBlock(api); const { hash, data, commit } = await uploadChunk(PROVIDER_URL, bucketId, payload, nonce); console.log(" Uploaded %d bytes, mmr_root=%s", data.length, commit.mmr_root); @@ -129,7 +133,7 @@ async function claimPaymentAfterExpiry(api: ParachainApi, papi: PolkadotClient, console.log(" Provider balance before:", freeBefore.toString()); console.log(" Waiting for agreement to expire..."); - await waitForBlock(papi, expiresAt); + await waitForRelayBlock(papi, api, expiresAt); await endAgreement(api, client, provider, bucketId, "Pay"); const freeAfter = ( @@ -209,7 +213,7 @@ async function main() { console.log(" Challenge defended"); console.log("\n=== Step 5: Submit checkpoint ==="); - const ckNonce = Number(await api.query.System.Number.getValue()); + const ckNonce = await currentRelayBlock(api); const ck = await fetchCheckpointSignature(PROVIDER_URL, bucketId, ckNonce); console.log(" Checkpoint mmr_root:", ck.mmr_root); console.log(" Checkpoint leaf_count:", ck.leaf_count); diff --git a/examples/papi/sc-coverage.ts b/examples/papi/sc-coverage.ts index 7ac9483c..4305720e 100644 --- a/examples/papi/sc-coverage.ts +++ b/examples/papi/sc-coverage.ts @@ -27,6 +27,7 @@ import { fileURLToPath } from "node:url"; import { connect, + currentRelayBlock, ensureProviderRegistered, fetchChallengeProof, fetchCheckpointSignature, @@ -265,9 +266,9 @@ async function main() { console.log(" bucketC =", bucketC.toString()); console.log(" preconditions: uploadChunk + submitClientCheckpoint"); - const uploadNonce = Number(await api.query.System.Number.getValue()); + const uploadNonce = await currentRelayBlock(api); const upload = await uploadChunk(providerUrl, bucketC, "coverage-test", uploadNonce); - const ckNonce = Number(await api.query.System.Number.getValue()); + const ckNonce = await currentRelayBlock(api); const ck = await fetchCheckpointSignature(providerUrl, bucketC, ckNonce); await submitClientCheckpoint(api, client, provider, bucketC, ck); diff --git a/examples/papi/sc-flow.ts b/examples/papi/sc-flow.ts index 0fd02e40..2215fd81 100644 --- a/examples/papi/sc-flow.ts +++ b/examples/papi/sc-flow.ts @@ -31,6 +31,7 @@ import { fileURLToPath } from "node:url"; import { challengeOffchain, connect, + currentRelayBlock, downloadChunk, ensureProviderRegistered, fetchChallengeProof, @@ -167,7 +168,7 @@ async function main() { // 5) Off-chain ops are unchanged — they bypass the contract entirely. console.log("\n[5/6] Off-chain upload + challenge round-trip…"); const payload = `Hello via SC! ${new Date().toISOString()}`; - const uploadNonce = Number(await api.query.System.Number.getValue()); + const uploadNonce = await currentRelayBlock(api); const upload = await uploadChunk(PROVIDER_URL, bucketId, payload, uploadNonce); const downloaded = await downloadChunk(PROVIDER_URL, upload.hash); assert.deepStrictEqual( diff --git a/packages/layer0/src/provider-http.ts b/packages/layer0/src/provider-http.ts index 699d0cdc..0c7eddf2 100644 --- a/packages/layer0/src/provider-http.ts +++ b/packages/layer0/src/provider-http.ts @@ -127,9 +127,10 @@ export async function uploadChunk( children: null, }, }); - // `nonce` is the block at which the caller intends to submit the extrinsic - // consuming the resulting `provider_signature`. The pallet rejects signatures - // whose nonce is older than `MaxNonceAge`, so thread it into /commit. + // `nonce` is the relay-chain block (see `currentRelayBlock`) at which the + // caller intends to submit the extrinsic consuming the resulting + // `provider_signature`. The pallet rejects signatures whose nonce is older + // than `MaxNonceAge`, so thread it into /commit. const commit = await providerFetch(providerUrl, "/commit", { method: "POST", body: { bucket_id: Number(bucketId), data_roots: [hash], nonce: Number(nonce) }, @@ -152,8 +153,9 @@ export async function fetchCheckpointSignature( bucketId: bigint | number, nonce: bigint | number, ): Promise { - // The provider signs the CommitmentPayload including `nonce`; pass the block - // the caller will submit at so the on-chain recency check passes. + // The provider signs the CommitmentPayload including `nonce`; pass the + // relay block (see `currentRelayBlock`) the caller will submit at so the + // on-chain recency check passes. return providerFetch(providerUrl, "/checkpoint-signature", { params: { bucket_id: bucketId, nonce: Number(nonce) }, }); diff --git a/packages/layer0/src/waits.ts b/packages/layer0/src/waits.ts index bb75f31e..e35a899e 100644 --- a/packages/layer0/src/waits.ts +++ b/packages/layer0/src/waits.ts @@ -11,7 +11,7 @@ */ import type { PolkadotClient } from "polkadot-api"; -import { filter, firstValueFrom, map, timeout, TimeoutError } from "rxjs"; +import { filter, firstValueFrom, map, switchMap, timeout, TimeoutError } from "rxjs"; import type { Observable } from "rxjs"; import type { ParachainApi } from "./address.js"; @@ -129,3 +129,48 @@ export async function waitForBlock( { timeoutMs: timeoutMs ?? null, description: `best block > ${target}` }, ); } + +/** + * Current relay-chain block number, read from + * `ParachainSystem.LastRelayChainBlockNumber` at the best block. + * + * Every on-chain duration (timeouts, checkpoint windows, `valid_until`, + * `CommitmentPayload.nonce` recency) is measured in relay-chain blocks, not + * parachain blocks — snapshot this, never `System.Number`, when building a + * nonce or window-timed extrinsic. + */ +export async function currentRelayBlock(api: ParachainApi): Promise { + return Number( + await api.query.ParachainSystem.LastRelayChainBlockNumber.getValue(READ_OPTS), + ); +} + +/** + * Wait until the relay-chain block anchored to the parachain's best head is + * strictly greater than `target`. + * + * The relay-block counterpart of [`waitForBlock`]: checkpoint windows and + * timeouts elapse on the relay clock, so window-timed extrinsics must wait on + * this one. + */ +export async function waitForRelayBlock( + papi: PolkadotClient, + api: ParachainApi, + target: number, + { logEvery = 5, timeoutMs }: { logEvery?: number; timeoutMs?: number } = {}, +): Promise { + await firstMatch( + papi.bestBlocks$.pipe( + switchMap(() => currentRelayBlock(api)), + map((n) => { + if (logEvery > 0 && n % logEvery === 0) { + console.log(" relay=#%d (target > %d)", n, target); + } + return n; + }), + ), + (n) => n > target, + // No default timeout: waiting out a long block window is the point here. + { timeoutMs: timeoutMs ?? null, description: `relay block > ${target}` }, + ); +} diff --git a/provider-node/src/chain_state_coordinator.rs b/provider-node/src/chain_state_coordinator.rs index b162d0c8..d755b45f 100644 --- a/provider-node/src/chain_state_coordinator.rs +++ b/provider-node/src/chain_state_coordinator.rs @@ -5,7 +5,8 @@ //! //! [`ChainState`] is the single source of truth for all on-chain state the //! provider node needs at runtime: -//! - [`ChainState::current_block`] — latest finalized block height. +//! - [`ChainState::current_block`] — relay-chain block anchored to the latest +//! finalized parachain block (the clock all on-chain durations use). //! - [`ChainState::constants`] — pallet constants fetched once on connect. //! - [`ChainState::provider_info`] — full provider registration info. //! - [`ChainState::nonce_counter`] — nonce counter bootstrapped from the @@ -41,7 +42,9 @@ use tokio::task::JoinHandle; /// Held behind `Arc` inside [`crate::ProviderState`] so the coordinator can hold /// its own handle without a back-reference to the whole node state. pub struct ChainState { - /// Latest finalized block height. `0` means not yet known. + /// Relay-chain block anchored to the latest finalized parachain block — + /// the clock all on-chain durations (timeouts, `valid_until`, nonce age) + /// are measured against. `0` means not yet known. pub current_block: AtomicU32, /// Pallet constants fetched once per connection. `None` until the first /// successful fetch; `/negotiate` returns 503 until this is `Some`. @@ -222,9 +225,21 @@ impl ChainStateCoordinator { let block_number = block.number(); tracing::debug!("Finalized block: {}", block_number); - self.chain_state - .current_block - .store(block_number, std::sync::atomic::Ordering::Relaxed); + // All on-chain durations (RequestTimeout, MaxNonceAge, expiries) + // are denominated in relay-chain blocks, so `current_block` must + // track the relay block anchored to this finalized block — not + // its parachain height. + match storage_client::substrate::fetch_last_relay_block_number(&block.storage()).await { + Ok(relay_block) => { + self.chain_state + .current_block + .store(relay_block, std::sync::atomic::Ordering::Relaxed); + } + Err(e) => tracing::warn!( + "chain-state coordinator: failed to fetch relay block for block \ + {block_number}: {e}; keeping previous value" + ), + } let parsed = match block.events().await { Ok(events) => parse_pallet_events::( diff --git a/provider-node/src/subxt_client.rs b/provider-node/src/subxt_client.rs index b68f26ed..64d9e5c7 100644 --- a/provider-node/src/subxt_client.rs +++ b/provider-node/src/subxt_client.rs @@ -61,17 +61,22 @@ impl SubxtChainClient { Ok(Self { api, signer }) } - /// Get the current (latest) block number. + /// Get the current relay-chain block number (the clock all on-chain + /// durations, in particular checkpoint windows, are measured against), + /// read at the latest parachain block. /// /// Backs `get_current_block` on both the checkpoint and replica-sync traits. async fn current_block(&self) -> Result { - let block = self + let storage = self .api - .blocks() + .storage() .at_latest() .await - .map_err(|e| Error::Internal(format!("Failed to get latest block: {e}")))?; - Ok(block.number() as u64) + .map_err(|e| Error::Internal(format!("Failed to get latest storage: {e}")))?; + storage_client::substrate::fetch_last_relay_block_number(&storage) + .await + .map(u64::from) + .map_err(|e| Error::Internal(e.to_string())) } /// Convert a multiaddr string to an HTTP endpoint. diff --git a/provider-node/src/types.rs b/provider-node/src/types.rs index 8a307158..b743ab05 100644 --- a/provider-node/src/types.rs +++ b/provider-node/src/types.rs @@ -65,7 +65,7 @@ pub struct CommitRequest { pub bucket_id: BucketId, /// Data roots to add to the MMR pub data_roots: Vec, - /// `CommitmentPayload` nonce — block at which the caller expects to + /// `CommitmentPayload` nonce — relay-chain block at which the caller expects to /// submit the resulting signature on-chain. The provider signs over /// this value so the pallet's recency check passes. pub nonce: u64, From 8d0442a550b3a4a003c4801e307bcd888b642eab Mon Sep 17 00:00:00 2001 From: Ilia Churin Date: Tue, 7 Jul 2026 15:04:11 +0900 Subject: [PATCH 03/20] Add provider integration tests for relay-clock semantics Coverage-gated (tests/*_integration.rs) additions: - /negotiate signs valid_until = relay current_block + RequestTimeout, asserted with a Paseo-scale relay number, and returns 503 chain_state_not_ready when either the clock or the constant is still unknown (both refusal branches were untested). Plus checkpoint-coordinator duty tests (coordinators target): windows derive from the relay-scale block number and move only when the relay clock crosses an interval boundary, never on a repeated relay parent (velocity > 1) or an intra-window advance. Provider coverage: 64.79% locally vs 64.59% base (the dip came from the coordinator's relay-fetch lines, which need a live chain; the gate ignores subxt_client.rs). --- .../tests/coordinators/checkpoint.rs | 44 ++++++++++++ provider-node/tests/negotiate_integration.rs | 69 +++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/provider-node/tests/coordinators/checkpoint.rs b/provider-node/tests/coordinators/checkpoint.rs index 8eaae975..4db4c04a 100644 --- a/provider-node/tests/coordinators/checkpoint.rs +++ b/provider-node/tests/coordinators/checkpoint.rs @@ -134,6 +134,50 @@ async fn test_duty_found_submit_ok() { assert_eq!(submitted[0], (1, 5)); } +/// `get_current_block` returns the relay-chain block number since the +/// relay-clock migration; the on-chain window is `relay_block / interval`, +/// so the coordinator must compute duties on that scale or its +/// `provider_checkpoint` submissions target the wrong window. +#[tokio::test] +async fn duty_window_derives_from_relay_scale_block() { + let mock = Arc::new(MockCheckpointChainClient::new(29_123_456)); + let state = test_state_with_bucket(1); + let config = CheckpointCoordinatorConfig::default(); + let coordinator = CheckpointCoordinator::new(config, state, Box::new(Arc::clone(&mock))); + + let duty = coordinator.get_checkpoint_duty(1).await.unwrap().unwrap(); + assert_eq!(duty.window, 291_234); // 29_123_456 / 100 + assert_eq!(duty.interval, 100); + assert_eq!(duty.grace_period, 20); +} + +/// The relay parent can repeat across consecutive parachain blocks +/// (velocity > 1) and advances within a window without changing it: the +/// duty window must move only when the relay clock crosses an interval +/// boundary, never on a mere re-read. +#[tokio::test] +async fn duty_window_moves_only_on_interval_boundary() { + let mock = Arc::new(MockCheckpointChainClient::new(29_123_456)); + let state = test_state_with_bucket(1); + let config = CheckpointCoordinatorConfig::default(); + let coordinator = CheckpointCoordinator::new(config, state, Box::new(Arc::clone(&mock))); + + // Repeated relay parent → identical duty. + let first = coordinator.get_checkpoint_duty(1).await.unwrap().unwrap(); + let repeat = coordinator.get_checkpoint_duty(1).await.unwrap().unwrap(); + assert_eq!(first.window, repeat.window); + + // Advancing within the window keeps it. + *mock.block_number.lock().unwrap() = 29_123_499; + let same_window = coordinator.get_checkpoint_duty(1).await.unwrap().unwrap(); + assert_eq!(same_window.window, first.window); + + // Crossing the interval boundary moves it by one. + *mock.block_number.lock().unwrap() = 29_123_500; + let next_window = coordinator.get_checkpoint_duty(1).await.unwrap().unwrap(); + assert_eq!(next_window.window, first.window + 1); +} + #[tokio::test] async fn test_submit_fails() { let mock = Arc::new( diff --git a/provider-node/tests/negotiate_integration.rs b/provider-node/tests/negotiate_integration.rs index 8ff0fbfc..13a51153 100644 --- a/provider-node/tests/negotiate_integration.rs +++ b/provider-node/tests/negotiate_integration.rs @@ -168,6 +168,75 @@ async fn negotiate_returns_signed_terms_with_valid_signature() { ); } +#[tokio::test] +async fn negotiate_valid_until_is_relay_block_plus_request_timeout() { + // `current_block` is the relay-chain block number — the clock the pallet + // checks `valid_until` against. Seed a Paseo-scale value to prove the + // validity window is anchored to it (a parachain-height-based window + // would be rejected on-chain as already expired). + let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); + state + .chain_state + .current_block + .store(29_123_456, std::sync::atomic::Ordering::Relaxed); + *state.chain_state.constants.write() = Some(PalletConstants { + request_timeout: 3_600, + }); + let counter = std::sync::Arc::new(NonceCounter::new(1)); + counter.bootstrap_from_hsn(0); + *state.chain_state.nonce_counter.write() = Some(counter); + *state.chain_state.provider_info.write() = Some(provider_info()); + let server = TestServer::serve(Arc::new(state)).await; + + let resp = server.negotiate(&primary_request()).await; + assert_eq!(resp.status(), StatusCode::OK); + let signed: SignedTerms = resp.json().await.unwrap(); + assert_eq!(signed.terms.valid_until, 29_123_456 + 3_600); +} + +#[tokio::test] +async fn negotiate_503_when_current_block_unknown() { + // Everything ready except the relay-block clock (`current_block == 0`, + // i.e. the chain-state coordinator has not processed a finalized block + // yet): signing would emit terms whose `valid_until` is meaningless on + // the pallet's clock, so the handler must refuse. + let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); + *state.chain_state.constants.write() = Some(PalletConstants { + request_timeout: 200, + }); + let counter = std::sync::Arc::new(NonceCounter::new(1)); + counter.bootstrap_from_hsn(0); + *state.chain_state.nonce_counter.write() = Some(counter); + *state.chain_state.provider_info.write() = Some(provider_info()); + let server = TestServer::serve(Arc::new(state)).await; + + let resp = server.negotiate(&primary_request()).await; + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["error"], "chain_state_not_ready"); +} + +#[tokio::test] +async fn negotiate_503_when_request_timeout_unknown() { + // The mirror case: clock known but the RequestTimeout constant not yet + // fetched — an unbounded validity window must not be signed either. + let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); + state + .chain_state + .current_block + .store(100, std::sync::atomic::Ordering::Relaxed); + let counter = std::sync::Arc::new(NonceCounter::new(1)); + counter.bootstrap_from_hsn(0); + *state.chain_state.nonce_counter.write() = Some(counter); + *state.chain_state.provider_info.write() = Some(provider_info()); + let server = TestServer::serve(Arc::new(state)).await; + + let resp = server.negotiate(&primary_request()).await; + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["error"], "chain_state_not_ready"); +} + #[tokio::test] async fn negotiate_pins_listed_price_when_client_overpays() { let server = TestServer::ready(provider_info()).await; From f636e25b1098707c6c19da749702b3c92d0576c4 Mon Sep 17 00:00:00 2001 From: Ilia Churin Date: Mon, 13 Jul 2026 18:11:14 +0900 Subject: [PATCH 04/20] Inline relay-block query in provider node, drop storage-client coupling #275 extracted the wire types into provider-negotiation and removed the provider node's storage-client dependency, but two callers still reached into storage_client::substrate::fetch_last_relay_block_number. Inline the one-shot ParachainSystem::LastRelayChainBlockNumber query as a pub(crate) helper in subxt_client so the node reads the relay clock over its own subxt connection without depending on the client SDK. --- provider-node/src/chain_state_coordinator.rs | 2 +- provider-node/src/subxt_client.rs | 37 +++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/provider-node/src/chain_state_coordinator.rs b/provider-node/src/chain_state_coordinator.rs index 737d4ae4..a0c3e055 100644 --- a/provider-node/src/chain_state_coordinator.rs +++ b/provider-node/src/chain_state_coordinator.rs @@ -322,7 +322,7 @@ impl ChainStateCoordinator { // are denominated in relay-chain blocks, so `current_block` must // track the relay block anchored to this finalized block — not // its parachain height. - match storage_client::substrate::fetch_last_relay_block_number(&block.storage()).await { + match crate::subxt_client::fetch_last_relay_block_number(&block.storage()).await { Ok(relay_block) => { self.chain_state .current_block diff --git a/provider-node/src/subxt_client.rs b/provider-node/src/subxt_client.rs index 64d9e5c7..a4765a5f 100644 --- a/provider-node/src/subxt_client.rs +++ b/provider-node/src/subxt_client.rs @@ -24,6 +24,38 @@ use storage_primitives::BucketId; use subxt::dynamic::Value; use subxt::ext::scale_value::value; +/// Fetch and decode `ParachainSystem::LastRelayChainBlockNumber` from a storage +/// view (`api.storage().at_latest()` or `block.storage()`). This is the relay +/// clock every on-chain duration (timeouts, expiries, `valid_until`, nonces) is +/// measured against, so off-chain actors must read it, not the parachain +/// block height. +/// +/// Inlined here so the provider node stays free of a `storage-client` +/// dependency (see #275). +pub(crate) async fn fetch_last_relay_block_number( + storage: &subxt::storage::Storage< + subxt::PolkadotConfig, + subxt::OnlineClient, + >, +) -> Result { + let addr = subxt::dynamic::storage( + "ParachainSystem", + "LastRelayChainBlockNumber", + Vec::::new(), + ); + let thunk = storage + .fetch(&addr) + .await + .map_err(|e| Error::Internal(format!("Failed to fetch relay block number: {e}")))? + .ok_or_else(|| Error::Internal("LastRelayChainBlockNumber not found".to_string()))?; + thunk + .to_value() + .map_err(|e| Error::Internal(format!("Failed to decode relay block number: {e}")))? + .as_u128() + .map(|v| v as u32) + .ok_or_else(|| Error::Internal("relay block number is not an integer".to_string())) +} + /// Production implementation that talks to the chain via subxt. /// /// Cloning is cheap: `OnlineClient` shares one connection behind an `Arc`, and @@ -73,10 +105,7 @@ impl SubxtChainClient { .at_latest() .await .map_err(|e| Error::Internal(format!("Failed to get latest storage: {e}")))?; - storage_client::substrate::fetch_last_relay_block_number(&storage) - .await - .map(u64::from) - .map_err(|e| Error::Internal(e.to_string())) + fetch_last_relay_block_number(&storage).await.map(u64::from) } /// Convert a multiaddr string to an HTTP endpoint. From 37797e2d1461ac079650a6709d1c3ec3ba5d4d42 Mon Sep 17 00:00:00 2001 From: Ilia Churin Date: Mon, 13 Jul 2026 18:11:23 +0900 Subject: [PATCH 05/20] Prefix relay-chain time constants with rc_ The relay_time module's MINUTES/HOURS are name-twins of the parachain time module's MINUTES/HOURS, so a call site like 48 * HOURS gave no hint which clock it measured. Rename the relay-chain units to RC_MINUTES / RC_HOURS in both runtimes (constants + storage.rs timeout params) so the relay-chain denomination is explicit at every use. Values unchanged; the parachain time::* units (Session Period/SessionLength) keep their names. --- runtimes/web3-storage-local/src/constants.rs | 4 ++-- runtimes/web3-storage-local/src/storage.rs | 12 ++++++------ runtimes/web3-storage-paseo/src/paseo_constants.rs | 4 ++-- runtimes/web3-storage-paseo/src/storage.rs | 12 ++++++------ runtimes/web3-storage-paseo/tests/tests.rs | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/runtimes/web3-storage-local/src/constants.rs b/runtimes/web3-storage-local/src/constants.rs index b1399546..9970cedb 100644 --- a/runtimes/web3-storage-local/src/constants.rs +++ b/runtimes/web3-storage-local/src/constants.rs @@ -56,9 +56,9 @@ pub mod time { pub mod relay_time { use crate::BlockNumber; - pub const MINUTES: BlockNumber = + pub const RC_MINUTES: BlockNumber = 60_000 / (super::consensus::RELAY_CHAIN_SLOT_DURATION_MILLIS as BlockNumber); - pub const HOURS: BlockNumber = MINUTES * 60; + pub const RC_HOURS: BlockNumber = RC_MINUTES * 60; } /// Constants relating to the native token. diff --git a/runtimes/web3-storage-local/src/storage.rs b/runtimes/web3-storage-local/src/storage.rs index 1d1f2341..33930273 100644 --- a/runtimes/web3-storage-local/src/storage.rs +++ b/runtimes/web3-storage-local/src/storage.rs @@ -10,7 +10,7 @@ use frame_support::{ use sp_runtime::traits::AccountIdConversion; use crate::{ - constants::{currency::UNIT, relay_time::HOURS}, + constants::{currency::UNIT, relay_time::RC_HOURS}, AccountId, Balance, Balances, BlockNumber, Runtime, RuntimeEvent, }; @@ -20,18 +20,18 @@ use crate::{ // their wall-clock meaning when the parachain block time changes. parameter_types! { pub const MinProviderStake: Balance = 1_000 * UNIT; // 1000 tokens minimum stake - pub const ChallengeTimeout: BlockNumber = 48 * HOURS; // 48 hours to respond + pub const ChallengeTimeout: BlockNumber = 48 * RC_HOURS; // 48 hours to respond // Replay-protection window for `CommitmentPayload::nonce`. A signature whose // nonce is older than this is rejected. Set wide enough to accommodate // normal off-chain choreography (provider signs, client builds & broadcasts // tx, tx finalises) without forcing re-signing. - pub const MaxNonceAge: BlockNumber = 24 * HOURS; + pub const MaxNonceAge: BlockNumber = 24 * RC_HOURS; // Reserved from the challenger when opening a challenge. 1 token at 12 // decimals = floor on spam economics. Previously hardcoded `100u32` // (1e-10 of a token) which made challenge spam effectively free. pub const ChallengeDeposit: Balance = UNIT; - pub const SettlementTimeout: BlockNumber = 24 * HOURS; - pub const RequestTimeout: BlockNumber = 6 * HOURS; + pub const SettlementTimeout: BlockNumber = 24 * RC_HOURS; + pub const RequestTimeout: BlockNumber = 6 * RC_HOURS; // 1 token (1e12) per 1 GB (1e9 bytes) = 1000 per byte pub const MinStakePerByte: Balance = 1_000; pub const DefaultCheckpointInterval: BlockNumber = 100; // relay blocks (~10 min) @@ -44,7 +44,7 @@ parameter_types! { /// pre-deregistration agreement quote expires before re-registration (the /// re-register replay defense). Both are checked in `integrity_test`. /// Value: the 48h challenge window plus a 6h grace. - pub const DeregisterAnnouncementPeriod: BlockNumber = 54 * HOURS; + pub const DeregisterAnnouncementPeriod: BlockNumber = 54 * RC_HOURS; /// Caps the challenges sharing one deadline (relay block) and the /// `on_initialize` sweep's per-block slash budget. Generous: only /// challenges created while the chain sits on the same relay parent diff --git a/runtimes/web3-storage-paseo/src/paseo_constants.rs b/runtimes/web3-storage-paseo/src/paseo_constants.rs index 50bf1db7..40adea0d 100644 --- a/runtimes/web3-storage-paseo/src/paseo_constants.rs +++ b/runtimes/web3-storage-paseo/src/paseo_constants.rs @@ -61,9 +61,9 @@ pub mod time { pub mod relay_time { use crate::BlockNumber; - pub const MINUTES: BlockNumber = + pub const RC_MINUTES: BlockNumber = 60_000 / (super::consensus::RELAY_CHAIN_SLOT_DURATION_MILLIS as BlockNumber); - pub const HOURS: BlockNumber = MINUTES * 60; + pub const RC_HOURS: BlockNumber = RC_MINUTES * 60; } /// Constants relating to the native token. diff --git a/runtimes/web3-storage-paseo/src/storage.rs b/runtimes/web3-storage-paseo/src/storage.rs index ff8a1414..30692849 100644 --- a/runtimes/web3-storage-paseo/src/storage.rs +++ b/runtimes/web3-storage-paseo/src/storage.rs @@ -11,7 +11,7 @@ use frame_support::{ use sp_runtime::traits::AccountIdConversion; use crate::{ - paseo_constants::{currency::UNIT, relay_time::HOURS}, + paseo_constants::{currency::UNIT, relay_time::RC_HOURS}, AccountId, Balance, Balances, BlockNumber, Runtime, RuntimeEvent, }; @@ -27,18 +27,18 @@ use crate::{ // their wall-clock meaning when the parachain block time changes. parameter_types! { pub storage MinProviderStake: Balance = 1_000 * UNIT; // 1000 tokens minimum stake - pub storage ChallengeTimeout: BlockNumber = 48 * HOURS; + pub storage ChallengeTimeout: BlockNumber = 48 * RC_HOURS; // Replay-protection window for `CommitmentPayload::nonce`. A signature whose // nonce is older than this is rejected. Set wide enough to accommodate // normal off-chain choreography (provider signs, client builds & broadcasts // tx, tx finalises) without forcing re-signing. - pub storage MaxNonceAge: BlockNumber = 24 * HOURS; + pub storage MaxNonceAge: BlockNumber = 24 * RC_HOURS; // Reserved from the challenger when opening a challenge. 1 token at 12 // decimals = floor on spam economics. Previously hardcoded `100u32` // (1e-10 of a token) which made challenge spam effectively free. pub storage ChallengeDeposit: Balance = UNIT; - pub storage SettlementTimeout: BlockNumber = 24 * HOURS; - pub storage RequestTimeout: BlockNumber = 6 * HOURS; + pub storage SettlementTimeout: BlockNumber = 24 * RC_HOURS; + pub storage RequestTimeout: BlockNumber = 6 * RC_HOURS; // 1 token (1e12) per 1 GB (1e9 bytes) = 1000 per byte pub storage MinStakePerByte: Balance = 1_000; pub storage DefaultCheckpointInterval: BlockNumber = 100; // relay blocks (~10 min) @@ -51,7 +51,7 @@ parameter_types! { /// pre-deregistration agreement quote expires before re-registration (the /// re-register replay defense). Both are checked in `integrity_test`. /// Value: the 48h challenge window plus a 6h grace. - pub storage DeregisterAnnouncementPeriod: BlockNumber = 54 * HOURS; + pub storage DeregisterAnnouncementPeriod: BlockNumber = 54 * RC_HOURS; /// Caps the challenges sharing one deadline (relay block) and the /// `on_initialize` sweep's per-block slash budget. Generous: only /// challenges created while the chain sits on the same relay parent diff --git a/runtimes/web3-storage-paseo/tests/tests.rs b/runtimes/web3-storage-paseo/tests/tests.rs index d3efc5df..b4ec609a 100644 --- a/runtimes/web3-storage-paseo/tests/tests.rs +++ b/runtimes/web3-storage-paseo/tests/tests.rs @@ -506,7 +506,7 @@ fn should_deregister_provider() { ); // Step 3: fast-forward through the announcement period. Jump close to - // the boundary with `set_clocks` (the period is `48 * HOURS` + // the boundary with `set_clocks` (the period is `48 * RC_HOURS` // ≈ 28,800 blocks, far too many to iterate one-by-one) then cross it // via `advance_block` so the `on_finalize` / `on_initialize` hooks // fire at the maturing block. From 06f74d475647bf8c5f2b7c5a4d68a01614b1c506 Mon Sep 17 00:00:00 2001 From: Ilia Churin Date: Thu, 16 Jul 2026 15:29:13 +0900 Subject: [PATCH 06/20] Simplify challenge-sweep doc comments Rewrite the on_initialize sweep docs as bullets with the safety formula (keys < previous relay parent are final), tighten MAX_SWEEP_SPAN and LastSweptChallengeBlock, and fix stale on_finalize references left over from the on_initialize move. Addresses review comments on #268. --- pallet/src/lib.rs | 72 +++++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 43 deletions(-) diff --git a/pallet/src/lib.rs b/pallet/src/lib.rs index 80821deb..994245c9 100644 --- a/pallet/src/lib.rs +++ b/pallet/src/lib.rs @@ -80,49 +80,37 @@ pub mod pallet { #[pallet::storage_version(STORAGE_VERSION)] pub struct Pallet(_); - /// Maximum number of deadline keys the challenge slash sweep drains per - /// block. Relay block numbers can jump by more than one between - /// consecutive parachain blocks (most drastically after a gap in block - /// production), so the sweep covers a range of keys; this cap bounds the - /// per-block key probing and the remainder carries over via - /// [`LastSweptChallengeBlock`]. The expensive part — slashing — is - /// bounded separately by the per-block challenge budget - /// (`MaxChallengesPerDeadline`) inside `on_initialize`. + /// Maximum deadline keys the slash sweep probes per block. Relay block + /// numbers can jump by more than one per parachain block, so the sweep + /// covers a range; this caps the probing and the remainder carries over via + /// [`LastSweptChallengeBlock`]. Slashing is bounded separately by + /// [`Config::MaxChallengesPerDeadline`]. const MAX_SWEEP_SPAN: u32 = 32; #[pallet::hooks] impl Hooks> for Pallet { /// Slash providers whose challenges expired unanswered. /// - /// Challenge deadlines are relay-chain block numbers - /// ([`Config::BlockNumberProvider`]), which advance by a variable - /// amount (including zero) between consecutive parachain blocks, so - /// the sweep drains a *range* of deadline keys and tracks its - /// progress in [`LastSweptChallengeBlock`] instead of probing the - /// single key `n` the way a parachain-block-keyed sweep could. + /// Deadlines are relay-chain blocks ([`Config::BlockNumberProvider`]), + /// which can jump by more than one per parachain block, so this drains a + /// *range* of deadline keys, tracking progress in + /// [`LastSweptChallengeBlock`] rather than probing the single key `n`. /// - /// At `on_initialize` time the validation-data inherent has not run - /// yet, so [`Pallet::current_block`] returns the relay parent `p` of - /// the *previous* parachain block. A challenge with deadline `d` is - /// respondable in any block whose relay parent is `<= d`, and every - /// later block has relay parent `>= p`, so exactly the keys `< p` - /// are final here: unrespondable, with `NextChallengeIndex` frozen - /// (any new challenge gets `deadline = now + ChallengeTimeout >= p`). - /// Draining them cannot race a valid response. The flip side is a - /// one-parachain-block lag: a slash lands in the first block *after* - /// the relay parent passes the deadline. Escape hatches don't care — - /// `complete_deregister`/`end_agreement` are gated by the - /// [`PendingChallenges`] counters, not by the sweep having run. - /// - /// Two independent bounds keep the block budget safe: [`MAX_SWEEP_SPAN`] - /// caps how many keys are probed, and a challenge budget of - /// [`Config::MaxChallengesPerDeadline`] caps how many slashes run — - /// the same worst case a single fully-loaded deadline always had. On - /// budget exhaustion the cursor parks just below the partially - /// drained key and the remainder carries over to later blocks. - /// - /// Runs in `on_initialize` rather than `on_finalize` so the actual - /// work done can be returned as weight instead of pre-reserved. + /// - **Which keys are final.** In `on_initialize` the validation-data + /// inherent has not run, so [`Pallet::current_block`] is the relay + /// parent `p` of the *previous* parachain block. A challenge with + /// deadline `d` stays respondable while some block has relay parent + /// `<= d`; every future block has relay parent `>= p`; so keys `< p` + /// are unrespondable and draining them cannot race a valid response. + /// Cost: a one-block lag — the slash lands the block after `p` passes + /// `d`. Escape hatches are unaffected; they gate on the + /// [`PendingChallenges`] counters, not on the sweep. + /// - **Budget.** [`MAX_SWEEP_SPAN`] caps keys probed per block; + /// [`Config::MaxChallengesPerDeadline`] caps slashes per block (the + /// worst case one full deadline always had). On exhaustion the cursor + /// parks just below the partly drained key; the rest carries over. + /// - **Why `on_initialize`.** Work done is returned as weight instead of + /// pre-reserved, which `on_finalize` cannot do. fn on_initialize(_n: BlockNumberFor) -> Weight { // Provider read + cursor read/write. let mut weight = T::DbWeight::get().reads_writes(2, 1); @@ -415,18 +403,16 @@ pub mod pallet { pub type NextChallengeIndex = StorageMap<_, Blake2_128Concat, BlockNumberFor, u16, ValueQuery>; - /// Highest deadline key (relay chain block) the `on_initialize` slash - /// sweep has already drained. The sweep covers the range from here up to - /// (but excluding) the previous block's relay parent, because relay block - /// numbers can advance by more than one between consecutive parachain - /// blocks. `None` until the first block after genesis/upgrade anchors it. + /// Highest deadline key the `on_initialize` slash sweep has drained. Each + /// block it sweeps up to (but excluding) the previous block's relay parent. + /// `None` until the first block after genesis/upgrade anchors it. #[pallet::storage] pub type LastSweptChallengeBlock = StorageValue<_, BlockNumberFor, OptionQuery>; /// Number of unresolved challenges currently outstanding against a /// provider, summed across every bucket. Incremented in `create_challenge` /// and decremented exactly once per resolution (defended/invalid-response - /// in `respond_to_challenge`, or timeout in `on_finalize`). Gates + /// in `respond_to_challenge`, or timeout in the `on_initialize` sweep). Gates /// `complete_deregister`: a provider cannot exit while still slashable for /// a pending challenge. #[pallet::storage] @@ -1103,7 +1089,7 @@ pub mod pallet { AgreementHasPendingChallenge, /// `MaxChallengesPerDeadline` challenges have already been allocated /// for the deadline this challenge would land on. Bounds the - /// `on_finalize` slash sweep so it stays within its reserved weight. + /// `on_initialize` slash sweep's per-block slash budget. TooManyChallengesThisBlock, // Checkpoint errors From feaf834ca62827c7a56698871c6115b887eef69c Mon Sep 17 00:00:00 2001 From: Ilia Churin Date: Thu, 16 Jul 2026 15:35:18 +0900 Subject: [PATCH 07/20] Fix on_initialize_slash_challenges benchmark to drive the real sweep The benchmark still called the dropped on_finalize hook, which is now the Hooks default no-op, so it measured nothing and under-charged the slash sweep. Anchor the cursor and relay clock so on_initialize drains exactly the loaded deadline key, and add a verify guard asserting the drain actually happened. Addresses review comment on #268. --- pallet/src/benchmarking.rs | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/pallet/src/benchmarking.rs b/pallet/src/benchmarking.rs index a6482cdb..7cf6c40e 100644 --- a/pallet/src/benchmarking.rs +++ b/pallet/src/benchmarking.rs @@ -1243,13 +1243,13 @@ mod benchmarks { ); } - /// `on_finalize` slash sweep: drains and slashes every challenge expiring - /// at a deadline. Linear in the challenge count `c`; each entry is drained, - /// its pending counters decremented, and its provider slashed. The cost is - /// charged up front in `on_initialize` via - /// `WeightInfo::on_initialize_slash_challenges(c)`. The upper bound is the - /// runtime cap `MaxChallengesPerDeadline` itself, so the linear fit covers - /// the true worst case rather than extrapolating to it. + /// `on_initialize` slash sweep: drains and slashes every challenge expiring + /// at a single deadline key. Linear in the challenge count `c`; each entry + /// is drained, its pending counters decremented, and its provider slashed. + /// The upper bound is the runtime cap `MaxChallengesPerDeadline` itself, so + /// the linear fit covers the true worst case rather than extrapolating to + /// it. The sweep applies this per key, so a small fixed hook overhead is + /// counted here and again in the hook's base weight — conservative. #[benchmark] fn on_initialize_slash_challenges(c: Linear<0, { T::MaxChallengesPerDeadline::get() as u32 }>) { let deadline: BlockNumberFor = 200u32.into(); @@ -1281,10 +1281,25 @@ mod benchmarks { } NextChallengeIndex::::insert(deadline, c as u16); + // Drive the real sweep over exactly one key. Anchor the cursor one + // below `deadline`, then set the relay clock so the sweepable range + // (keys < previous relay parent) is exactly `{deadline}`: + // `sweepable = current_block() - 1 = deadline`, `end = deadline`. + LastSweptChallengeBlock::::put(deadline.saturating_sub(1u32.into())); + let now = deadline.saturating_add(1u32.into()); + set_block_number::(now); + #[block] { - StorageProvider::::on_finalize(deadline); + StorageProvider::::on_initialize(now); } + + // Guard against the sweep silently no-op'ing (the bug this benchmark + // had while it still called the dropped `on_finalize`): every challenge + // at the deadline must have been drained. (At the worst-case component + // `c == MaxChallengesPerDeadline` the slash budget is exactly spent, so + // the cursor parks at `deadline - 1` and carries over — expected.) + assert_eq!(Challenges::::iter_prefix(deadline).count(), 0); } impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test); From 82f603041f866773cc8a8c82274a2dc25fae5f9d Mon Sep 17 00:00:00 2001 From: Ilia Churin Date: Thu, 16 Jul 2026 15:35:43 +0900 Subject: [PATCH 08/20] Update slashing flow diagram for the on_initialize range sweep The Automatic Slashing sequence still showed the old on_finalize(block_number) single-key take. Reflect the relay-block range drain tracked by LastSweptChallengeBlock. Addresses review comment on #268. --- docs/design/EXECUTION_FLOWS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/design/EXECUTION_FLOWS.md b/docs/design/EXECUTION_FLOWS.md index 001fbf0f..c75f14c6 100644 --- a/docs/design/EXECUTION_FLOWS.md +++ b/docs/design/EXECUTION_FLOWS.md @@ -538,9 +538,11 @@ sequenceDiagram participant C as Chain (Pallet) participant B as Balances - Note over C: on_finalize(block_number) hook + Note over C: on_initialize(n) slash sweep - C->>C: expired = Challenges::take(block_number) + Note over C: Deadlines are relay-chain blocks. Drain every deadline key in
(LastSweptChallengeBlock, previous relay parent], budget-capped. + + C->>C: expired = Challenges::drain_prefix(deadline_key) loop For each expired challenge Note over C: Provider failed to respond! From 5bc92a3e60b87877f1144169c00d376b5d330c35 Mon Sep 17 00:00:00 2001 From: Ilia Churin Date: Thu, 16 Jul 2026 16:06:20 +0900 Subject: [PATCH 09/20] Cap the sweep's per-block slash budget below MaxChallengesPerDeadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single deadline can hold up to MaxChallengesPerDeadline (1000) challenges; slashing all of them in one block costs ~5 MB PoV — the whole block budget. Introduce MAX_SWEEP_SLASH_BUDGET (100) and cap the per-block budget at min(MaxChallengesPerDeadline, MAX_SWEEP_SLASH_BUDGET), letting a full deadline drain over several blocks via the existing carry-over cursor. Tighten the benchmark component to the effective budget so weight regeneration stays within the sweep's real ceiling. Addresses review comment on #268. --- pallet/src/benchmarking.rs | 24 +++++++++++++++++++----- pallet/src/lib.rs | 29 ++++++++++++++++++++--------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/pallet/src/benchmarking.rs b/pallet/src/benchmarking.rs index 7cf6c40e..7d7be7f6 100644 --- a/pallet/src/benchmarking.rs +++ b/pallet/src/benchmarking.rs @@ -1246,12 +1246,26 @@ mod benchmarks { /// `on_initialize` slash sweep: drains and slashes every challenge expiring /// at a single deadline key. Linear in the challenge count `c`; each entry /// is drained, its pending counters decremented, and its provider slashed. - /// The upper bound is the runtime cap `MaxChallengesPerDeadline` itself, so - /// the linear fit covers the true worst case rather than extrapolating to - /// it. The sweep applies this per key, so a small fixed hook overhead is - /// counted here and again in the hook's base weight — conservative. + /// The upper bound is the effective per-block slash budget + /// `min(MaxChallengesPerDeadline, MAX_SWEEP_SLASH_BUDGET)` — the most the + /// sweep ever slashes for one key in a block — so the linear fit covers the + /// true worst case rather than extrapolating to it. The sweep applies this + /// per key, so a small fixed hook overhead is counted here and again in the + /// hook's base weight — conservative. #[benchmark] - fn on_initialize_slash_challenges(c: Linear<0, { T::MaxChallengesPerDeadline::get() as u32 }>) { + fn on_initialize_slash_challenges( + c: Linear< + 0, + { + let cap = T::MaxChallengesPerDeadline::get() as u32; + if cap < crate::pallet::MAX_SWEEP_SLASH_BUDGET { + cap + } else { + crate::pallet::MAX_SWEEP_SLASH_BUDGET + } + }, + >, + ) { let deadline: BlockNumberFor = 200u32.into(); let deposit: BalanceOf = 100u32.into(); for i in 0..c { diff --git a/pallet/src/lib.rs b/pallet/src/lib.rs index 994245c9..9737cb26 100644 --- a/pallet/src/lib.rs +++ b/pallet/src/lib.rs @@ -84,9 +84,18 @@ pub mod pallet { /// numbers can jump by more than one per parachain block, so the sweep /// covers a range; this caps the probing and the remainder carries over via /// [`LastSweptChallengeBlock`]. Slashing is bounded separately by - /// [`Config::MaxChallengesPerDeadline`]. + /// [`MAX_SWEEP_SLASH_BUDGET`]. const MAX_SWEEP_SPAN: u32 = 32; + /// Maximum challenges the slash sweep slashes per block, across all deadline + /// keys it touches. Decoupled from [`Config::MaxChallengesPerDeadline`] (up + /// to 1000) because slashing that many in one block would consume the whole + /// block's PoV (~5 KB each). A fully loaded deadline instead drains over + /// several blocks via the [`LastSweptChallengeBlock`] carry-over. The + /// effective budget is `min(MaxChallengesPerDeadline, MAX_SWEEP_SLASH_BUDGET)`, + /// so runtimes with a smaller per-deadline cap (e.g. tests) are unaffected. + pub(crate) const MAX_SWEEP_SLASH_BUDGET: u32 = 100; + #[pallet::hooks] impl Hooks> for Pallet { /// Slash providers whose challenges expired unanswered. @@ -106,9 +115,9 @@ pub mod pallet { /// `d`. Escape hatches are unaffected; they gate on the /// [`PendingChallenges`] counters, not on the sweep. /// - **Budget.** [`MAX_SWEEP_SPAN`] caps keys probed per block; - /// [`Config::MaxChallengesPerDeadline`] caps slashes per block (the - /// worst case one full deadline always had). On exhaustion the cursor - /// parks just below the partly drained key; the rest carries over. + /// [`MAX_SWEEP_SLASH_BUDGET`] caps slashes per block so one maturing + /// deadline cannot eat the block's PoV. On exhaustion the cursor parks + /// just below the partly drained key; the rest carries over. /// - **Why `on_initialize`.** Work done is returned as weight instead of /// pre-reserved, which `on_finalize` cannot do. fn on_initialize(_n: BlockNumberFor) -> Weight { @@ -138,9 +147,11 @@ pub mod pallet { return weight; } let end = sweepable.min(last.saturating_add(MAX_SWEEP_SPAN.into())); - // Per-block slash budget. `.max(1)` so a (nonsensical) zero cap - // cannot park the cursor forever. - let mut budget = u32::from(T::MaxChallengesPerDeadline::get()).max(1); + // Per-block slash budget, capped so one maturing deadline cannot eat + // the whole block's PoV. Lower bound of 1 so a (nonsensical) zero + // cap cannot park the cursor forever. + let mut budget = + u32::from(T::MaxChallengesPerDeadline::get()).clamp(1, MAX_SWEEP_SLASH_BUDGET); let mut key = last.saturating_add(One::one()); while key <= end { let mut count: u32 = 0; @@ -1088,8 +1099,8 @@ pub mod pallet { /// resolves (defended, slashed, or timed out). AgreementHasPendingChallenge, /// `MaxChallengesPerDeadline` challenges have already been allocated - /// for the deadline this challenge would land on. Bounds the - /// `on_initialize` slash sweep's per-block slash budget. + /// for the deadline this challenge would land on. Caps the total the + /// `on_initialize` sweep must eventually drain for a single key. TooManyChallengesThisBlock, // Checkpoint errors From 392b58fb18e7b66b1d81ab0a3a18c2c73c258e77 Mon Sep 17 00:00:00 2001 From: Ilia Churin Date: Thu, 16 Jul 2026 16:10:31 +0900 Subject: [PATCH 10/20] Rename ChainState.current_block to current_relay_block The field holds the relay-chain block anchored to the latest finalized parachain block, not the parachain height. Rename it (and the local it feeds in /negotiate) so the relay-clock semantics are self-evident, resolving the ambiguity flagged in review. The separate get_current_block trait methods are untouched. Addresses review comment on #268. --- provider-node/src/api.rs | 14 ++++++------- provider-node/src/chain_state_coordinator.rs | 22 ++++++++++---------- provider-node/src/command.rs | 9 ++++---- provider-node/src/lib.rs | 10 +++++++-- provider-node/src/negotiate.rs | 2 +- 5 files changed, 32 insertions(+), 25 deletions(-) diff --git a/provider-node/src/api.rs b/provider-node/src/api.rs index caab577d..33a16a7e 100644 --- a/provider-node/src/api.rs +++ b/provider-node/src/api.rs @@ -897,8 +897,8 @@ async fn get_historical_roots( /// /// Returns one of several `503`s when a prerequisite is missing: /// - `signing_unavailable` — no `--keyfile`. -/// - `chain_state_not_ready` — `current_block` and `request_timeout` are not both -/// known from the chain yet. +/// - `chain_state_not_ready` — `current_relay_block` and `request_timeout` are +/// not both known from the chain yet. /// - `provider_info_unavailable` — provider not registered on chain yet; the /// chain-state coordinator clears this automatically once registration lands, no /// restart needed. @@ -912,11 +912,11 @@ async fn negotiate_terms( ) -> Result, Error> { let keypair = state.keypair.as_ref().ok_or(Error::SigningUnavailable)?; - // Both current_block and RequestTimeout must be known before we can sign — + // Both the relay block and RequestTimeout must be known before we can sign — // otherwise we'd emit unbounded or already-expired terms. - let current_block = state + let relay_block = state .chain_state - .current_block + .current_relay_block .load(std::sync::atomic::Ordering::Relaxed); let request_timeout = state .chain_state @@ -925,7 +925,7 @@ async fn negotiate_terms( .as_ref() .map(|c| c.request_timeout) .unwrap_or(0); - if current_block == 0 || request_timeout == 0 { + if relay_block == 0 || request_timeout == 0 { return Err(Error::ChainStateNotReady); } @@ -966,7 +966,7 @@ async fn negotiate_terms( max_bytes: req.max_bytes, duration: req.duration, price_per_byte: info.price_per_byte, - valid_until: current_block.saturating_add(request_timeout), + valid_until: relay_block.saturating_add(request_timeout), nonce: nonce_counter.next(), bucket_id: req.bucket_id, replica_params: req.replica_params, diff --git a/provider-node/src/chain_state_coordinator.rs b/provider-node/src/chain_state_coordinator.rs index a0c3e055..d1f611e3 100644 --- a/provider-node/src/chain_state_coordinator.rs +++ b/provider-node/src/chain_state_coordinator.rs @@ -5,8 +5,8 @@ //! //! [`ChainState`] is the single source of truth for all on-chain state the //! provider node needs at runtime: -//! - [`ChainState::current_block`] — relay-chain block anchored to the latest -//! finalized parachain block (the clock all on-chain durations use). +//! - [`ChainState::current_relay_block`] — relay-chain block anchored to the +//! latest finalized parachain block (the clock all on-chain durations use). //! - [`ChainState::constants`] — pallet constants fetched once on connect. //! - [`ChainState::provider_info`] — full provider registration info. //! - [`ChainState::nonce_counter`] — nonce counter bootstrapped from the @@ -45,7 +45,7 @@ pub struct ChainState { /// Relay-chain block anchored to the latest finalized parachain block — /// the clock all on-chain durations (timeouts, `valid_until`, nonce age) /// are measured against. `0` means not yet known. - pub current_block: AtomicU32, + pub current_relay_block: AtomicU32, /// Pallet constants fetched once per connection. `None` until the first /// successful fetch; `/negotiate` returns 503 until this is `Some`. pub constants: RwLock>, @@ -67,7 +67,7 @@ pub struct ChainState { impl Default for ChainState { fn default() -> Self { Self { - current_block: AtomicU32::new(0), + current_relay_block: AtomicU32::new(0), constants: RwLock::new(None), provider_info: RwLock::new(None), nonce_counter: RwLock::new(None), @@ -319,13 +319,13 @@ impl ChainStateCoordinator { tracing::debug!("Finalized block: {}", block_number); // All on-chain durations (RequestTimeout, MaxNonceAge, expiries) - // are denominated in relay-chain blocks, so `current_block` must - // track the relay block anchored to this finalized block — not + // are denominated in relay-chain blocks, so `current_relay_block` + // must track the relay block anchored to this finalized block — not // its parachain height. match crate::subxt_client::fetch_last_relay_block_number(&block.storage()).await { Ok(relay_block) => { self.chain_state - .current_block + .current_relay_block .store(relay_block, std::sync::atomic::Ordering::Relaxed); } Err(e) => tracing::warn!( @@ -706,17 +706,17 @@ mod tests { #[test] fn chain_state_defaults_to_unknown() { let cs = ChainState::default(); - assert_eq!(cs.current_block.load(Ordering::Relaxed), 0); + assert_eq!(cs.current_relay_block.load(Ordering::Relaxed), 0); assert!(cs.constants.read().is_none()); assert!(cs.provider_info.read().is_none()); assert!(cs.nonce_counter.read().is_none()); } #[test] - fn chain_state_current_block_round_trips() { + fn chain_state_current_relay_block_round_trips() { let cs = ChainState::default(); - cs.current_block.store(42, Ordering::Relaxed); - assert_eq!(cs.current_block.load(Ordering::Relaxed), 42); + cs.current_relay_block.store(42, Ordering::Relaxed); + assert_eq!(cs.current_relay_block.load(Ordering::Relaxed), 42); } #[test] diff --git a/provider-node/src/command.rs b/provider-node/src/command.rs index b06ed715..9b49371b 100644 --- a/provider-node/src/command.rs +++ b/provider-node/src/command.rs @@ -166,13 +166,14 @@ fn configure_state(state: ProviderState, cli: &Cli) -> ProviderState { state } -/// Start the chain-state coordinator, which keeps `chain_state.current_block` -/// and `chain_state.provider_info` in sync with the chain. +/// Start the chain-state coordinator, which keeps +/// `chain_state.current_relay_block` and `chain_state.provider_info` in sync +/// with the chain. /// /// Returns `None` only when the provider id isn't a valid account. The /// coordinator itself never fails to start: it connects in the background and -/// retries with a backoff if the chain is unreachable, so `current_block` is -/// populated as soon as the chain comes up. +/// retries with a backoff if the chain is unreachable, so `current_relay_block` +/// is populated as soon as the chain comes up. fn start_chain_state_coordinator( cli: &Cli, state: Arc, diff --git a/provider-node/src/lib.rs b/provider-node/src/lib.rs index 3aef0ab2..3ea6a136 100644 --- a/provider-node/src/lib.rs +++ b/provider-node/src/lib.rs @@ -92,7 +92,7 @@ pub struct ProviderState { /// permissive policy; `Some(list)` restricts to exactly those origins. pub cors_allowed_origins: Option>, /// Live chain state kept in sync by the chain-state coordinator — the single - /// writer for `current_block`, `constants`, `provider_info`, and + /// writer for `current_relay_block`, `constants`, `provider_info`, and /// `nonce_counter`. `/negotiate` gates on all four before signing. pub chain_state: Arc, } @@ -308,7 +308,13 @@ mod tests { fn provider_state_chain_defaults_on_new() { use std::sync::atomic::Ordering; let state = ProviderState::with_provider_id(test_storage(), "test-provider".to_string()); - assert_eq!(state.chain_state.current_block.load(Ordering::Relaxed), 0); + assert_eq!( + state + .chain_state + .current_relay_block + .load(Ordering::Relaxed), + 0 + ); assert!(state.chain_state.provider_info.read().is_none()); } } diff --git a/provider-node/src/negotiate.rs b/provider-node/src/negotiate.rs index 88da8973..8ed660e2 100644 --- a/provider-node/src/negotiate.rs +++ b/provider-node/src/negotiate.rs @@ -13,7 +13,7 @@ //! out-of-range reuse). //! 2. Builds [`AgreementTerms`] from the request, the provider's current //! `price_per_byte` setting (read from chain), and -//! `valid_until = current_block + valid_until_offset`. +//! `valid_until = current_relay_block + valid_until_offset`. //! 3. Signs `blake2_256(TERM_CONTEXT | SCALE(terms))` with the provider's //! existing sr25519 checkpoint key (the same one used to sign //! commitments). The context is `primary-term-v1:` or From a3c35f65c7e3a730b0726b5e407f5c72efc78b97 Mon Sep 17 00:00:00 2001 From: Ilia Churin Date: Thu, 16 Jul 2026 16:12:19 +0900 Subject: [PATCH 11/20] Add try-state invariant for the challenge sweep cursor Assert no unresolved challenge sits at or below LastSweptChallengeBlock: the sweep drains everything up to its cursor and new challenges always land above it, so a violation flags a stranded-unslashed challenge (e.g. an un-migrated upgrade leaving keys below the anchor). Addresses review comment on #268. --- pallet/src/lib.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pallet/src/lib.rs b/pallet/src/lib.rs index 9737cb26..9c5f6d20 100644 --- a/pallet/src/lib.rs +++ b/pallet/src/lib.rs @@ -223,6 +223,27 @@ pub mod pallet { provider is still slashable" ); } + + /// Invariant: no unresolved challenge sits at or below the swept cursor. + /// The sweep drains every deadline key up to its cursor (parking one + /// below a key it only partly drained), and `create_challenge` always + /// sets `deadline = now + ChallengeTimeout`, which is above the cursor — + /// so anything at or below it must already have been drained. A + /// violation means a challenge was stranded unslashed (e.g. an upgrade + /// that left parachain-denominated keys below the anchor). + #[cfg(feature = "try-runtime")] + fn try_state(_n: BlockNumberFor) -> Result<(), sp_runtime::TryRuntimeError> { + if let Some(cursor) = LastSweptChallengeBlock::::get() { + for (deadline, _index, _challenge) in Challenges::::iter() { + ensure!( + deadline > cursor, + "LastSweptChallengeBlock: an unresolved challenge sits at or \ + below the swept cursor" + ); + } + } + Ok(()) + } } #[pallet::config] From 427e9a7b142a38ca976e937b30e22666d608d2b6 Mon Sep 17 00:00:00 2001 From: Ilia Churin Date: Thu, 16 Jul 2026 16:32:05 +0900 Subject: [PATCH 12/20] Re-home the sweep-cursor invariant into the try_state module The merge with dev adopted its Pallet::do_try_state() structure, which superseded the inline try_state hook from the earlier commit. Move the sweep-cursor invariant (no unresolved challenge at or below LastSweptChallengeBlock) in as check_challenge_sweep_cursor (P1.5), with a detection test matching the module's convention. --- pallet/src/impls/try_state.rs | 20 ++++++++++++++++++++ pallet/src/tests/try_state.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/pallet/src/impls/try_state.rs b/pallet/src/impls/try_state.rs index 9a2cc304..6d656c30 100644 --- a/pallet/src/impls/try_state.rs +++ b/pallet/src/impls/try_state.rs @@ -19,6 +19,7 @@ impl Pallet { Self::check_timing_config()?; Self::check_committed_bytes()?; Self::check_buckets_and_membership()?; + Self::check_challenge_sweep_cursor()?; Ok(()) } @@ -36,6 +37,25 @@ impl Pallet { Ok(()) } + /// P1.5: no unresolved challenge sits at or below the swept cursor. The + /// `on_initialize` sweep drains every deadline key up to its cursor (parking + /// one below a key it only partly drained), and `create_challenge` always + /// sets `deadline = now + ChallengeTimeout`, above the cursor — so anything + /// at or below it must already have been drained. A violation means a + /// challenge was stranded unslashed (e.g. an upgrade that left + /// parachain-denominated keys below the anchor). + fn check_challenge_sweep_cursor() -> Result<(), TryRuntimeError> { + if let Some(cursor) = LastSweptChallengeBlock::::get() { + for (deadline, _index, _challenge) in Challenges::::iter() { + ensure!( + deadline > cursor, + "unresolved challenge sits at or below the swept cursor" + ); + } + } + Ok(()) + } + /// P1.1: each provider's `committed_bytes` equals the sum of `max_bytes` /// over all its storage agreements (the accounting that gates capacity and /// required stake). diff --git a/pallet/src/tests/try_state.rs b/pallet/src/tests/try_state.rs index 705b2125..6694b6d5 100644 --- a/pallet/src/tests/try_state.rs +++ b/pallet/src/tests/try_state.rs @@ -67,3 +67,34 @@ fn try_state_detects_member_index_gap() { assert!(StorageProvider::do_try_state().is_err()); }); } + +/// P1.5: a challenge still pending at a deadline the sweep cursor claims to have +/// passed is caught — it means the challenge was stranded unslashed. +#[test] +fn try_state_detects_challenge_below_sweep_cursor() { + new_test_ext().execute_with(|| { + assert_ok!(StorageProvider::do_try_state()); + + let deadline = 100u64; + Challenges::::insert( + deadline, + 0u16, + Challenge:: { + bucket_id: 0, + provider: 2, + challenger: 1, + mmr_root: sp_core::H256::zero(), + start_seq: 0, + target: storage_primitives::ChunkLocation { + leaf_index: 0, + chunk_index: 0, + }, + deposit: 0, + }, + ); + // Cursor claims everything up to `deadline` is swept, yet a challenge is + // still pending there. + LastSweptChallengeBlock::::put(deadline); + assert!(StorageProvider::do_try_state().is_err()); + }); +} From aeb580c40d7de800b67ce847dbfaba4e96d5f579 Mon Sep 17 00:00:00 2001 From: Ilia Churin Date: Fri, 17 Jul 2026 01:29:57 +0900 Subject: [PATCH 13/20] Fix stale current_block field refs in provider-node integration tests The ChainState.current_block -> current_relay_block rename missed the tests/ integration files (the earlier clippy check omitted --all-targets, so they were never compiled). Rename the field accesses so `cargo clippy --all-targets` and the integration tests build again. --- provider-node/tests/chain_state_integration.rs | 8 ++++---- provider-node/tests/negotiate_integration.rs | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/provider-node/tests/chain_state_integration.rs b/provider-node/tests/chain_state_integration.rs index 9e3b553f..348bcd1e 100644 --- a/provider-node/tests/chain_state_integration.rs +++ b/provider-node/tests/chain_state_integration.rs @@ -77,7 +77,7 @@ async fn coordinator_leaves_state_at_defaults_while_chain_unreachable() { // A chain it can't reach must never produce state: every field stays at the // default that makes `/negotiate` return 503. - assert_eq!(chain_state.current_block.load(Ordering::Relaxed), 0); + assert_eq!(chain_state.current_relay_block.load(Ordering::Relaxed), 0); assert!(chain_state.constants.read().is_none()); assert!(chain_state.provider_info.read().is_none()); assert!(chain_state.nonce_counter.read().is_none()); @@ -140,7 +140,7 @@ async fn coordinator_keeps_retrying_without_panicking() { // dirties state. If the task had panicked, `stop()`'s join would surface it. for _ in 0..3 { tokio::time::sleep(Duration::from_millis(60)).await; - assert_eq!(chain_state.current_block.load(Ordering::Relaxed), 0); + assert_eq!(chain_state.current_relay_block.load(Ordering::Relaxed), 0); assert!(chain_state.provider_info.read().is_none()); } @@ -300,7 +300,7 @@ async fn refresh_clears_info_and_counter_when_not_registered() { // provider is not (or no longer) registered — both fields are dropped so // `/negotiate` reports `provider_info_unavailable`. let cs = ChainState::default(); - cs.current_block.store(100, Ordering::Relaxed); + cs.current_relay_block.store(100, Ordering::Relaxed); *cs.constants.write() = Some(PalletConstants { request_timeout: 200, }); @@ -315,7 +315,7 @@ async fn refresh_clears_info_and_counter_when_not_registered() { assert!(cs.provider_info.read().is_none()); assert!(cs.nonce_counter.read().is_none()); // Per-connection constants and the block height are not tied to registration. - assert_eq!(cs.current_block.load(Ordering::Relaxed), 100); + assert_eq!(cs.current_relay_block.load(Ordering::Relaxed), 100); assert_eq!(cs.constants.read().as_ref().unwrap().request_timeout, 200); } diff --git a/provider-node/tests/negotiate_integration.rs b/provider-node/tests/negotiate_integration.rs index a5a801f0..ea3f7146 100644 --- a/provider-node/tests/negotiate_integration.rs +++ b/provider-node/tests/negotiate_integration.rs @@ -43,7 +43,7 @@ impl TestServer { // Together these satisfy every `/negotiate` prerequisite. state .chain_state - .current_block + .current_relay_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -177,7 +177,7 @@ async fn negotiate_valid_until_is_relay_block_plus_request_timeout() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_block + .current_relay_block .store(29_123_456, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 3_600, @@ -223,7 +223,7 @@ async fn negotiate_503_when_request_timeout_unknown() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_block + .current_relay_block .store(100, std::sync::atomic::Ordering::Relaxed); let counter = std::sync::Arc::new(NonceCounter::new(1)); counter.bootstrap_from_hsn(0); @@ -319,7 +319,7 @@ async fn negotiate_503_when_provider_info_unavailable() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_block + .current_relay_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -405,7 +405,7 @@ async fn negotiate_transitions_to_info_unavailable_after_complete_deregister() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_block + .current_relay_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -455,7 +455,7 @@ async fn negotiate_recovers_after_deregister_cancelled() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_block + .current_relay_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -498,7 +498,7 @@ async fn negotiate_503_when_nonce_counter_absent() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_block + .current_relay_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -522,7 +522,7 @@ async fn negotiate_503_when_nonce_counter_present_but_not_bootstrapped() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_block + .current_relay_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, From cc230568ef070709b628e81094b7e3245af2c06d Mon Sep 17 00:00:00 2001 From: Ilia Churin Date: Fri, 17 Jul 2026 01:46:32 +0900 Subject: [PATCH 14/20] Expose current_anchor_block runtime API; make provider block-agnostic Per review: the provider should not reach into ParachainSystem's LastRelayChainBlockNumber (a relay-specific storage item) to learn the clock on-chain durations are measured against. Introduce a block-notion-agnostic abstraction: - Pallet: rename Pallet::current_block -> current_anchor_block (the sole place the BlockNumberProvider is read) and expose it via a new StorageProviderApi::current_anchor_block runtime API, implemented in both runtimes. - Provider: replace the dynamic LastRelayChainBlockNumber storage read with a dynamic current_anchor_block runtime-API call, and rename ChainState.current_relay_block -> current_anchor_block. The provider no longer knows or cares whether the anchor is a relay, parachain, or future block number. - Registries call sites updated to current_anchor_block. Behavior-preserving: the runtime API resolves to the same value the provider read before. Note: crates/storage-subxt static bindings are regenerated from node metadata and do not yet include the new API; the provider uses a dynamic call, so this is not a blocker. --- pallet/src/benchmarking.rs | 12 ++-- pallet/src/impls/agreements.rs | 4 +- pallet/src/impls/buckets.rs | 2 +- pallet/src/impls/challenges.rs | 2 +- pallet/src/impls/providers.rs | 2 +- pallet/src/impls/queries.rs | 16 +++-- pallet/src/impls/signatures.rs | 2 +- pallet/src/lib.rs | 32 +++++----- pallet/src/runtime_api.rs | 6 ++ provider-node/src/api.rs | 14 ++--- provider-node/src/chain_state_coordinator.rs | 44 +++++++------- provider-node/src/command.rs | 4 +- provider-node/src/lib.rs | 4 +- provider-node/src/negotiate.rs | 2 +- provider-node/src/subxt_client.rs | 59 ++++++++++--------- .../tests/chain_state_integration.rs | 8 +-- provider-node/tests/negotiate_integration.rs | 24 ++++---- runtimes/web3-storage-local/src/lib.rs | 4 ++ runtimes/web3-storage-paseo/src/lib.rs | 4 ++ runtimes/web3-storage-paseo/tests/tests.rs | 2 +- .../pallet-registry/src/benchmarking.rs | 2 +- .../file-system/pallet-registry/src/lib.rs | 2 +- .../s3/pallet-s3-registry/src/benchmarking.rs | 4 +- .../s3/pallet-s3-registry/src/lib.rs | 10 ++-- 24 files changed, 145 insertions(+), 120 deletions(-) diff --git a/pallet/src/benchmarking.rs b/pallet/src/benchmarking.rs index 7d7be7f6..45966877 100644 --- a/pallet/src/benchmarking.rs +++ b/pallet/src/benchmarking.rs @@ -88,7 +88,8 @@ fn build_primary_terms( max_bytes, duration, price_per_byte: 1u32.into(), - valid_until: StorageProvider::::current_block().saturating_add(T::RequestTimeout::get()), + valid_until: StorageProvider::::current_anchor_block() + .saturating_add(T::RequestTimeout::get()), nonce, bucket_id: None, replica_params: None, @@ -108,7 +109,8 @@ fn build_replica_terms( max_bytes, duration, price_per_byte: 1u32.into(), - valid_until: StorageProvider::::current_block().saturating_add(T::RequestTimeout::get()), + valid_until: StorageProvider::::current_anchor_block() + .saturating_add(T::RequestTimeout::get()), nonce, bucket_id: Some(bucket_id), replica_params: Some(ReplicaTerms { @@ -185,7 +187,7 @@ fn add_primary_to_bucket( bucket_id: BucketId, max_bytes: u64, ) { - let current_block = StorageProvider::::current_block(); + let current_block = StorageProvider::::current_anchor_block(); let duration: BlockNumberFor = 100u32.into(); let expires_at = current_block.saturating_add(duration); @@ -331,7 +333,7 @@ mod benchmarks { let _ = Pallet::::deregister_provider(RawOrigin::Signed(provider.clone()).into()); // Advance past `DeregisterAnnouncementPeriod` so completion is allowed. - let announce_block = StorageProvider::::current_block(); + let announce_block = StorageProvider::::current_anchor_block(); let complete_after = announce_block.saturating_add(T::DeregisterAnnouncementPeriod::get()); set_block_number::(complete_after); @@ -611,7 +613,7 @@ mod benchmarks { // Advance block past agreement expiry + settlement timeout let agreement = StorageProvider::::storage_agreements(bucket_id, &provider).unwrap(); - let current_block = StorageProvider::::current_block(); + let current_block = StorageProvider::::current_anchor_block(); let target_block: BlockNumberFor = agreement .expires_at .saturating_add(T::SettlementTimeout::get()) diff --git a/pallet/src/impls/agreements.rs b/pallet/src/impls/agreements.rs index 22dc4c39..24d31e0d 100644 --- a/pallet/src/impls/agreements.rs +++ b/pallet/src/impls/agreements.rs @@ -167,7 +167,7 @@ impl Pallet { // Quote must not be stale and must not exceed the chain-enforced window. // `terms.valid_until` must in range [current_block, current_block + RequestTimeout] - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); ensure!(terms.valid_until >= current_block, Error::::TermsExpired); ensure!( terms.valid_until <= current_block.saturating_add(T::RequestTimeout::get()), @@ -297,7 +297,7 @@ impl Pallet { ensure!(terms.max_bytes > 0, Error::::InvalidMaxBytesRequest); // Quote must not be stale and must not exceed the chain-enforced window. - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); ensure!(terms.valid_until >= current_block, Error::::TermsExpired); ensure!( terms.valid_until <= current_block.saturating_add(T::RequestTimeout::get()), diff --git a/pallet/src/impls/buckets.rs b/pallet/src/impls/buckets.rs index 2f2c1e3b..1cd6f4c0 100644 --- a/pallet/src/impls/buckets.rs +++ b/pallet/src/impls/buckets.rs @@ -44,7 +44,7 @@ impl Pallet { for (provider, agreement) in agreements { // Calculate prorated refund based on remaining time - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); let remaining_blocks = agreement.expires_at.saturating_sub(current_block); // If there's remaining time, calculate prorated refund diff --git a/pallet/src/impls/challenges.rs b/pallet/src/impls/challenges.rs index 8516d0ec..12e40e9f 100644 --- a/pallet/src/impls/challenges.rs +++ b/pallet/src/impls/challenges.rs @@ -28,7 +28,7 @@ impl Pallet { T::Currency::reserve(&challenger, deposit)?; - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); let deadline = current_block.saturating_add(T::ChallengeTimeout::get()); let challenge = Challenge { diff --git a/pallet/src/impls/providers.rs b/pallet/src/impls/providers.rs index 7e3e39b7..a2101960 100644 --- a/pallet/src/impls/providers.rs +++ b/pallet/src/impls/providers.rs @@ -86,7 +86,7 @@ impl Pallet { // Reserve stake T::Currency::reserve(who, stake)?; - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); let provider_info = ProviderInfo { multiaddr, diff --git a/pallet/src/impls/queries.rs b/pallet/src/impls/queries.rs index 21ede9d0..61649f61 100644 --- a/pallet/src/impls/queries.rs +++ b/pallet/src/impls/queries.rs @@ -62,13 +62,17 @@ fn agreement_to_response( } impl Pallet { - /// Current block number on the clock all pallet durations are measured - /// in: the relay chain in production, `System` in tests. + /// The anchor block: the clock every pallet duration (timeouts, expiries, + /// `valid_until`, nonce age) is measured against. Whether that is a relay + /// chain block, parachain block, or something else is a + /// [`Config::BlockNumberProvider`] detail — callers (including off-chain + /// consumers via the `current_anchor_block` runtime API) need not care. + /// The relay chain in production, `System` in tests. /// - /// During `on_initialize` this returns the *previous* block's relay - /// parent (the validation-data inherent has not run yet); everywhere - /// else it is the current block's relay parent. - pub fn current_block() -> BlockNumberFor { + /// During `on_initialize` this returns the *previous* block's anchor value + /// (the validation-data inherent has not run yet); everywhere else it is + /// the current block's. + pub fn current_anchor_block() -> BlockNumberFor { ::current_block_number() } diff --git a/pallet/src/impls/signatures.rs b/pallet/src/impls/signatures.rs index 11da3fa4..1561b933 100644 --- a/pallet/src/impls/signatures.rs +++ b/pallet/src/impls/signatures.rs @@ -17,7 +17,7 @@ impl Pallet { /// of) the current block. This prevents an attacker who captures one /// signed commitment from replaying it forever. pub(crate) fn ensure_recent_nonce(nonce: u64) -> DispatchResult { - let current: u64 = Self::current_block().saturated_into(); + let current: u64 = Self::current_anchor_block().saturated_into(); let max_age: u64 = T::MaxNonceAge::get().saturated_into(); // Future-dated nonces are nonsensical — the signer can only know // the current block at sign-time. Allow exact equality. diff --git a/pallet/src/lib.rs b/pallet/src/lib.rs index ce555f8b..76f4835f 100644 --- a/pallet/src/lib.rs +++ b/pallet/src/lib.rs @@ -128,7 +128,7 @@ pub mod pallet { // Provider read + cursor read/write. let mut weight = T::DbWeight::get().reads_writes(2, 1); - let now = Self::current_block(); + let now = Self::current_anchor_block(); if now.is_zero() { return weight; } @@ -1262,7 +1262,7 @@ pub mod pallet { #[pallet::weight(T::WeightInfo::deregister_provider())] pub fn deregister_provider(origin: OriginFor) -> DispatchResult { let who = ensure_signed(origin)?; - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); let complete_after = current_block.saturating_add(T::DeregisterAnnouncementPeriod::get()); @@ -1315,7 +1315,7 @@ pub mod pallet { let deregister_at = provider .deregister_at .ok_or(Error::::DeregisterNotAnnounced)?; - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); ensure!( current_block >= deregister_at, Error::::DeregisterPeriodNotElapsed @@ -1471,7 +1471,7 @@ pub mod pallet { Error::::ProviderNotFound ); - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); StorageAgreements::::try_mutate( bucket_id, @@ -1799,7 +1799,7 @@ pub mod pallet { Error::::AgreementHasPendingChallenge ); - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); let is_early_termination = current_block < agreement.expires_at; @@ -1854,7 +1854,7 @@ pub mod pallet { Error::::AgreementHasPendingChallenge ); - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); ensure!( current_block > agreement.expires_at, @@ -1902,7 +1902,7 @@ pub mod pallet { ensure!(agreement.owner == who, Error::::NotAgreementOwner); - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); let remaining_duration = if current_block < agreement.expires_at { agreement.expires_at.saturating_sub(current_block) } else { @@ -2006,7 +2006,7 @@ pub mod pallet { // Validate duration Self::validate_duration(&provider_info.settings, additional_duration)?; - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); // Check if price increased let price_increased = @@ -2180,7 +2180,7 @@ pub mod pallet { Error::::InsufficientSignatures ); - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); // Update historical roots Self::update_historical_roots(bucket, current_block, commitment.mmr_root); @@ -2324,7 +2324,7 @@ pub mod pallet { ensure!(config.enabled, Error::::ProviderCheckpointsDisabled); // Get current block and calculate current window - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); let current_window = Self::calculate_window(current_block, config.interval); // Validate window @@ -2521,7 +2521,7 @@ pub mod pallet { ensure!(config.enabled, Error::::ProviderCheckpointsDisabled); // Get current window - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); let current_window = Self::calculate_window(current_block, config.interval); // Can only report past windows @@ -2688,7 +2688,7 @@ pub mod pallet { let agreement = StorageAgreements::::get(bucket_id, &provider) .ok_or(Error::::AgreementNotFound)?; ensure!( - Self::current_block() < agreement.expires_at, + Self::current_anchor_block() < agreement.expires_at, Error::::AgreementExpired ); @@ -2749,7 +2749,7 @@ pub mod pallet { let agreement = StorageAgreements::::get(bucket_id, &provider) .ok_or(Error::::AgreementNotFound)?; ensure!( - Self::current_block() < agreement.expires_at, + Self::current_anchor_block() < agreement.expires_at, Error::::AgreementExpired ); @@ -2798,7 +2798,7 @@ pub mod pallet { // Challengeability tracks genuine obligation: only while the // agreement is live (not into the settlement window). ensure!( - Self::current_block() < agreement.expires_at, + Self::current_anchor_block() < agreement.expires_at, Error::::AgreementExpired ); @@ -2854,7 +2854,7 @@ pub mod pallet { ensure!(challenge.provider == who, Error::::NotChallengeProvider); - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); ensure!( current_block <= challenge_id.deadline, Error::::ChallengeExpired @@ -3093,7 +3093,7 @@ pub mod pallet { ProviderRole::Primary => return Err(Error::::NotReplica.into()), }; - let current_block = Self::current_block(); + let current_block = Self::current_anchor_block(); // Check sync interval if let Some(record) = last_sync { diff --git a/pallet/src/runtime_api.rs b/pallet/src/runtime_api.rs index 459babab..2c50e369 100644 --- a/pallet/src/runtime_api.rs +++ b/pallet/src/runtime_api.rs @@ -191,5 +191,11 @@ sp_api::decl_runtime_apis! { /// Get providers with sufficient capacity for the given bytes (paginated). fn providers_with_capacity(bytes_needed: u64, offset: u32, limit: u32) -> Vec<(AccountId, ProviderInfoResponse)>; + + /// The anchor block every on-chain duration (timeouts, expiries, + /// `valid_until`, nonce age) is measured against. Off-chain actors read + /// this instead of a specific storage item so they need not know whether + /// the anchor is a relay, parachain, or other block number. + fn current_anchor_block() -> BlockNumber; } } diff --git a/provider-node/src/api.rs b/provider-node/src/api.rs index 33a16a7e..adef9bf4 100644 --- a/provider-node/src/api.rs +++ b/provider-node/src/api.rs @@ -897,7 +897,7 @@ async fn get_historical_roots( /// /// Returns one of several `503`s when a prerequisite is missing: /// - `signing_unavailable` — no `--keyfile`. -/// - `chain_state_not_ready` — `current_relay_block` and `request_timeout` are +/// - `chain_state_not_ready` — `current_anchor_block` and `request_timeout` are /// not both known from the chain yet. /// - `provider_info_unavailable` — provider not registered on chain yet; the /// chain-state coordinator clears this automatically once registration lands, no @@ -912,11 +912,11 @@ async fn negotiate_terms( ) -> Result, Error> { let keypair = state.keypair.as_ref().ok_or(Error::SigningUnavailable)?; - // Both the relay block and RequestTimeout must be known before we can sign — - // otherwise we'd emit unbounded or already-expired terms. - let relay_block = state + // Both the anchor block and RequestTimeout must be known before we can sign + // — otherwise we'd emit unbounded or already-expired terms. + let anchor_block = state .chain_state - .current_relay_block + .current_anchor_block .load(std::sync::atomic::Ordering::Relaxed); let request_timeout = state .chain_state @@ -925,7 +925,7 @@ async fn negotiate_terms( .as_ref() .map(|c| c.request_timeout) .unwrap_or(0); - if relay_block == 0 || request_timeout == 0 { + if anchor_block == 0 || request_timeout == 0 { return Err(Error::ChainStateNotReady); } @@ -966,7 +966,7 @@ async fn negotiate_terms( max_bytes: req.max_bytes, duration: req.duration, price_per_byte: info.price_per_byte, - valid_until: relay_block.saturating_add(request_timeout), + valid_until: anchor_block.saturating_add(request_timeout), nonce: nonce_counter.next(), bucket_id: req.bucket_id, replica_params: req.replica_params, diff --git a/provider-node/src/chain_state_coordinator.rs b/provider-node/src/chain_state_coordinator.rs index d1f611e3..ba3970fd 100644 --- a/provider-node/src/chain_state_coordinator.rs +++ b/provider-node/src/chain_state_coordinator.rs @@ -5,8 +5,8 @@ //! //! [`ChainState`] is the single source of truth for all on-chain state the //! provider node needs at runtime: -//! - [`ChainState::current_relay_block`] — relay-chain block anchored to the -//! latest finalized parachain block (the clock all on-chain durations use). +//! - [`ChainState::current_anchor_block`] — the pallet's anchor block (the +//! clock all on-chain durations use), read via its runtime API. //! - [`ChainState::constants`] — pallet constants fetched once on connect. //! - [`ChainState::provider_info`] — full provider registration info. //! - [`ChainState::nonce_counter`] — nonce counter bootstrapped from the @@ -42,10 +42,13 @@ const PALLET_NAME: &str = "StorageProvider"; /// Held behind `Arc` inside [`crate::ProviderState`] so the coordinator can hold /// its own handle without a back-reference to the whole node state. pub struct ChainState { - /// Relay-chain block anchored to the latest finalized parachain block — - /// the clock all on-chain durations (timeouts, `valid_until`, nonce age) - /// are measured against. `0` means not yet known. - pub current_relay_block: AtomicU32, + /// The pallet's anchor block — the clock all on-chain durations (timeouts, + /// `valid_until`, nonce age) are measured against — read via the + /// `StorageProviderApi::current_anchor_block` runtime API at the latest + /// finalized block. Whether that anchor is a relay, parachain, or other + /// block number is the pallet's concern, not the provider's. `0` means not + /// yet known. + pub current_anchor_block: AtomicU32, /// Pallet constants fetched once per connection. `None` until the first /// successful fetch; `/negotiate` returns 503 until this is `Some`. pub constants: RwLock>, @@ -67,7 +70,7 @@ pub struct ChainState { impl Default for ChainState { fn default() -> Self { Self { - current_relay_block: AtomicU32::new(0), + current_anchor_block: AtomicU32::new(0), constants: RwLock::new(None), provider_info: RwLock::new(None), nonce_counter: RwLock::new(None), @@ -318,18 +321,19 @@ impl ChainStateCoordinator { let block_number = block.number(); tracing::debug!("Finalized block: {}", block_number); - // All on-chain durations (RequestTimeout, MaxNonceAge, expiries) - // are denominated in relay-chain blocks, so `current_relay_block` - // must track the relay block anchored to this finalized block — not - // its parachain height. - match crate::subxt_client::fetch_last_relay_block_number(&block.storage()).await { - Ok(relay_block) => { + // Track the pallet's anchor block (the clock all on-chain durations + // are measured against) at this finalized block, via the runtime + // API — so the provider never needs to know which block notion the + // pallet uses. + let runtime_api = chain.api.runtime_api().at(block.reference()); + match crate::subxt_client::fetch_current_anchor_block(&runtime_api).await { + Ok(anchor_block) => { self.chain_state - .current_relay_block - .store(relay_block, std::sync::atomic::Ordering::Relaxed); + .current_anchor_block + .store(anchor_block, std::sync::atomic::Ordering::Relaxed); } Err(e) => tracing::warn!( - "chain-state coordinator: failed to fetch relay block for block \ + "chain-state coordinator: failed to fetch anchor block for block \ {block_number}: {e}; keeping previous value" ), } @@ -706,17 +710,17 @@ mod tests { #[test] fn chain_state_defaults_to_unknown() { let cs = ChainState::default(); - assert_eq!(cs.current_relay_block.load(Ordering::Relaxed), 0); + assert_eq!(cs.current_anchor_block.load(Ordering::Relaxed), 0); assert!(cs.constants.read().is_none()); assert!(cs.provider_info.read().is_none()); assert!(cs.nonce_counter.read().is_none()); } #[test] - fn chain_state_current_relay_block_round_trips() { + fn chain_state_current_anchor_block_round_trips() { let cs = ChainState::default(); - cs.current_relay_block.store(42, Ordering::Relaxed); - assert_eq!(cs.current_relay_block.load(Ordering::Relaxed), 42); + cs.current_anchor_block.store(42, Ordering::Relaxed); + assert_eq!(cs.current_anchor_block.load(Ordering::Relaxed), 42); } #[test] diff --git a/provider-node/src/command.rs b/provider-node/src/command.rs index 9b49371b..9bb1afc5 100644 --- a/provider-node/src/command.rs +++ b/provider-node/src/command.rs @@ -167,12 +167,12 @@ fn configure_state(state: ProviderState, cli: &Cli) -> ProviderState { } /// Start the chain-state coordinator, which keeps -/// `chain_state.current_relay_block` and `chain_state.provider_info` in sync +/// `chain_state.current_anchor_block` and `chain_state.provider_info` in sync /// with the chain. /// /// Returns `None` only when the provider id isn't a valid account. The /// coordinator itself never fails to start: it connects in the background and -/// retries with a backoff if the chain is unreachable, so `current_relay_block` +/// retries with a backoff if the chain is unreachable, so `current_anchor_block` /// is populated as soon as the chain comes up. fn start_chain_state_coordinator( cli: &Cli, diff --git a/provider-node/src/lib.rs b/provider-node/src/lib.rs index 3ea6a136..2c65bac3 100644 --- a/provider-node/src/lib.rs +++ b/provider-node/src/lib.rs @@ -92,7 +92,7 @@ pub struct ProviderState { /// permissive policy; `Some(list)` restricts to exactly those origins. pub cors_allowed_origins: Option>, /// Live chain state kept in sync by the chain-state coordinator — the single - /// writer for `current_relay_block`, `constants`, `provider_info`, and + /// writer for `current_anchor_block`, `constants`, `provider_info`, and /// `nonce_counter`. `/negotiate` gates on all four before signing. pub chain_state: Arc, } @@ -311,7 +311,7 @@ mod tests { assert_eq!( state .chain_state - .current_relay_block + .current_anchor_block .load(Ordering::Relaxed), 0 ); diff --git a/provider-node/src/negotiate.rs b/provider-node/src/negotiate.rs index 8ed660e2..0569c2bf 100644 --- a/provider-node/src/negotiate.rs +++ b/provider-node/src/negotiate.rs @@ -13,7 +13,7 @@ //! out-of-range reuse). //! 2. Builds [`AgreementTerms`] from the request, the provider's current //! `price_per_byte` setting (read from chain), and -//! `valid_until = current_relay_block + valid_until_offset`. +//! `valid_until = current_anchor_block + valid_until_offset`. //! 3. Signs `blake2_256(TERM_CONTEXT | SCALE(terms))` with the provider's //! existing sr25519 checkpoint key (the same one used to sign //! commitments). The context is `primary-term-v1:` or diff --git a/provider-node/src/subxt_client.rs b/provider-node/src/subxt_client.rs index a4765a5f..e24eb577 100644 --- a/provider-node/src/subxt_client.rs +++ b/provider-node/src/subxt_client.rs @@ -24,36 +24,35 @@ use storage_primitives::BucketId; use subxt::dynamic::Value; use subxt::ext::scale_value::value; -/// Fetch and decode `ParachainSystem::LastRelayChainBlockNumber` from a storage -/// view (`api.storage().at_latest()` or `block.storage()`). This is the relay -/// clock every on-chain duration (timeouts, expiries, `valid_until`, nonces) is -/// measured against, so off-chain actors must read it, not the parachain -/// block height. +/// Query the pallet's `StorageProviderApi::current_anchor_block` runtime API — +/// the block every on-chain duration (timeouts, expiries, `valid_until`, nonce +/// age) is measured against. Reading it through the runtime API keeps the +/// provider agnostic to whether the anchor is a relay, parachain, or other +/// block number: the pallet decides via its `BlockNumberProvider`, and the +/// provider no longer reaches into a specific storage item. /// -/// Inlined here so the provider node stays free of a `storage-client` -/// dependency (see #275). -pub(crate) async fn fetch_last_relay_block_number( - storage: &subxt::storage::Storage< +/// Kept here (rather than in `storage-client`) so the provider node stays +/// dependency-light (see #275). +pub(crate) async fn fetch_current_anchor_block( + runtime_api: &subxt::runtime_api::RuntimeApi< subxt::PolkadotConfig, subxt::OnlineClient, >, ) -> Result { - let addr = subxt::dynamic::storage( - "ParachainSystem", - "LastRelayChainBlockNumber", + let call = subxt::dynamic::runtime_api_call( + "StorageProviderApi", + "current_anchor_block", Vec::::new(), ); - let thunk = storage - .fetch(&addr) + runtime_api + .call(call) .await - .map_err(|e| Error::Internal(format!("Failed to fetch relay block number: {e}")))? - .ok_or_else(|| Error::Internal("LastRelayChainBlockNumber not found".to_string()))?; - thunk + .map_err(|e| Error::Internal(format!("current_anchor_block runtime API call failed: {e}")))? .to_value() - .map_err(|e| Error::Internal(format!("Failed to decode relay block number: {e}")))? + .map_err(|e| Error::Internal(format!("Failed to decode anchor block: {e}")))? .as_u128() .map(|v| v as u32) - .ok_or_else(|| Error::Internal("relay block number is not an integer".to_string())) + .ok_or_else(|| Error::Internal("anchor block is not an integer".to_string())) } /// Production implementation that talks to the chain via subxt. @@ -93,19 +92,21 @@ impl SubxtChainClient { Ok(Self { api, signer }) } - /// Get the current relay-chain block number (the clock all on-chain - /// durations, in particular checkpoint windows, are measured against), - /// read at the latest parachain block. + /// Get the current anchor block (the clock all on-chain durations, in + /// particular checkpoint windows, are measured against), read at the latest + /// finalized state via the pallet's runtime API. /// /// Backs `get_current_block` on both the checkpoint and replica-sync traits. - async fn current_block(&self) -> Result { - let storage = self + async fn current_anchor_block(&self) -> Result { + let runtime_api = self .api - .storage() + .runtime_api() .at_latest() .await - .map_err(|e| Error::Internal(format!("Failed to get latest storage: {e}")))?; - fetch_last_relay_block_number(&storage).await.map(u64::from) + .map_err(|e| Error::Internal(format!("Failed to get latest runtime API: {e}")))?; + fetch_current_anchor_block(&runtime_api) + .await + .map(u64::from) } /// Convert a multiaddr string to an HTTP endpoint. @@ -456,7 +457,7 @@ impl SubxtChainClient { #[async_trait::async_trait] impl CheckpointChainClient for SubxtChainClient { async fn get_current_block(&self) -> Result { - self.current_block().await + self.current_anchor_block().await } async fn fetch_checkpoint_config( @@ -563,7 +564,7 @@ impl CheckpointChainClient for SubxtChainClient { #[async_trait::async_trait] impl ReplicaSyncChainClient for SubxtChainClient { async fn get_current_block(&self) -> Result { - self.current_block().await + self.current_anchor_block().await } async fn fetch_replica_agreements( diff --git a/provider-node/tests/chain_state_integration.rs b/provider-node/tests/chain_state_integration.rs index 348bcd1e..c408959c 100644 --- a/provider-node/tests/chain_state_integration.rs +++ b/provider-node/tests/chain_state_integration.rs @@ -77,7 +77,7 @@ async fn coordinator_leaves_state_at_defaults_while_chain_unreachable() { // A chain it can't reach must never produce state: every field stays at the // default that makes `/negotiate` return 503. - assert_eq!(chain_state.current_relay_block.load(Ordering::Relaxed), 0); + assert_eq!(chain_state.current_anchor_block.load(Ordering::Relaxed), 0); assert!(chain_state.constants.read().is_none()); assert!(chain_state.provider_info.read().is_none()); assert!(chain_state.nonce_counter.read().is_none()); @@ -140,7 +140,7 @@ async fn coordinator_keeps_retrying_without_panicking() { // dirties state. If the task had panicked, `stop()`'s join would surface it. for _ in 0..3 { tokio::time::sleep(Duration::from_millis(60)).await; - assert_eq!(chain_state.current_relay_block.load(Ordering::Relaxed), 0); + assert_eq!(chain_state.current_anchor_block.load(Ordering::Relaxed), 0); assert!(chain_state.provider_info.read().is_none()); } @@ -300,7 +300,7 @@ async fn refresh_clears_info_and_counter_when_not_registered() { // provider is not (or no longer) registered — both fields are dropped so // `/negotiate` reports `provider_info_unavailable`. let cs = ChainState::default(); - cs.current_relay_block.store(100, Ordering::Relaxed); + cs.current_anchor_block.store(100, Ordering::Relaxed); *cs.constants.write() = Some(PalletConstants { request_timeout: 200, }); @@ -315,7 +315,7 @@ async fn refresh_clears_info_and_counter_when_not_registered() { assert!(cs.provider_info.read().is_none()); assert!(cs.nonce_counter.read().is_none()); // Per-connection constants and the block height are not tied to registration. - assert_eq!(cs.current_relay_block.load(Ordering::Relaxed), 100); + assert_eq!(cs.current_anchor_block.load(Ordering::Relaxed), 100); assert_eq!(cs.constants.read().as_ref().unwrap().request_timeout, 200); } diff --git a/provider-node/tests/negotiate_integration.rs b/provider-node/tests/negotiate_integration.rs index ea3f7146..5476c7fa 100644 --- a/provider-node/tests/negotiate_integration.rs +++ b/provider-node/tests/negotiate_integration.rs @@ -43,7 +43,7 @@ impl TestServer { // Together these satisfy every `/negotiate` prerequisite. state .chain_state - .current_relay_block + .current_anchor_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -169,15 +169,15 @@ async fn negotiate_returns_signed_terms_with_valid_signature() { } #[tokio::test] -async fn negotiate_valid_until_is_relay_block_plus_request_timeout() { - // `current_block` is the relay-chain block number — the clock the pallet +async fn negotiate_valid_until_is_anchor_block_plus_request_timeout() { + // `current_anchor_block` is the pallet's anchor clock — the block the pallet // checks `valid_until` against. Seed a Paseo-scale value to prove the // validity window is anchored to it (a parachain-height-based window // would be rejected on-chain as already expired). let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_relay_block + .current_anchor_block .store(29_123_456, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 3_600, @@ -195,8 +195,8 @@ async fn negotiate_valid_until_is_relay_block_plus_request_timeout() { } #[tokio::test] -async fn negotiate_503_when_current_block_unknown() { - // Everything ready except the relay-block clock (`current_block == 0`, +async fn negotiate_503_when_anchor_block_unknown() { + // Everything ready except the anchor clock (`current_anchor_block == 0`, // i.e. the chain-state coordinator has not processed a finalized block // yet): signing would emit terms whose `valid_until` is meaningless on // the pallet's clock, so the handler must refuse. @@ -223,7 +223,7 @@ async fn negotiate_503_when_request_timeout_unknown() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_relay_block + .current_anchor_block .store(100, std::sync::atomic::Ordering::Relaxed); let counter = std::sync::Arc::new(NonceCounter::new(1)); counter.bootstrap_from_hsn(0); @@ -319,7 +319,7 @@ async fn negotiate_503_when_provider_info_unavailable() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_relay_block + .current_anchor_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -405,7 +405,7 @@ async fn negotiate_transitions_to_info_unavailable_after_complete_deregister() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_relay_block + .current_anchor_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -455,7 +455,7 @@ async fn negotiate_recovers_after_deregister_cancelled() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_relay_block + .current_anchor_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -498,7 +498,7 @@ async fn negotiate_503_when_nonce_counter_absent() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_relay_block + .current_anchor_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, @@ -522,7 +522,7 @@ async fn negotiate_503_when_nonce_counter_present_but_not_bootstrapped() { let state = ProviderState::with_seed(Arc::new(Storage::new()), PROVIDER_SEED).unwrap(); state .chain_state - .current_relay_block + .current_anchor_block .store(100, std::sync::atomic::Ordering::Relaxed); *state.chain_state.constants.write() = Some(PalletConstants { request_timeout: 200, diff --git a/runtimes/web3-storage-local/src/lib.rs b/runtimes/web3-storage-local/src/lib.rs index a8542c3f..66527612 100644 --- a/runtimes/web3-storage-local/src/lib.rs +++ b/runtimes/web3-storage-local/src/lib.rs @@ -833,6 +833,10 @@ pallet_revive::impl_runtime_apis_plus_revive_traits!( ) -> Vec<(AccountId, pallet_storage_provider::runtime_api::ProviderInfoResponse)> { StorageProvider::query_providers_with_capacity(bytes_needed, offset, limit) } + + fn current_anchor_block() -> BlockNumber { + StorageProvider::current_anchor_block() + } } impl xcm_runtime_apis::fees::XcmPaymentApi for Runtime { diff --git a/runtimes/web3-storage-paseo/src/lib.rs b/runtimes/web3-storage-paseo/src/lib.rs index 6529b984..3cbdc207 100644 --- a/runtimes/web3-storage-paseo/src/lib.rs +++ b/runtimes/web3-storage-paseo/src/lib.rs @@ -842,6 +842,10 @@ pallet_revive::impl_runtime_apis_plus_revive_traits!( ) -> Vec<(AccountId, pallet_storage_provider::runtime_api::ProviderInfoResponse)> { StorageProvider::query_providers_with_capacity(bytes_needed, offset, limit) } + + fn current_anchor_block() -> BlockNumber { + StorageProvider::current_anchor_block() + } } impl xcm_runtime_apis::fees::XcmPaymentApi for Runtime { diff --git a/runtimes/web3-storage-paseo/tests/tests.rs b/runtimes/web3-storage-paseo/tests/tests.rs index b4ec609a..94bdd8a8 100644 --- a/runtimes/web3-storage-paseo/tests/tests.rs +++ b/runtimes/web3-storage-paseo/tests/tests.rs @@ -167,7 +167,7 @@ fn primary_terms( price_per_byte: 0, // `valid_until` is checked against the pallet's relay-chain clock, // not the parachain height. - valid_until: pallet_storage_provider::Pallet::::current_block() + valid_until: pallet_storage_provider::Pallet::::current_anchor_block() + ::RequestTimeout::get(), nonce, bucket_id: None, diff --git a/storage-interfaces/file-system/pallet-registry/src/benchmarking.rs b/storage-interfaces/file-system/pallet-registry/src/benchmarking.rs index 81a41b52..68e513a1 100644 --- a/storage-interfaces/file-system/pallet-registry/src/benchmarking.rs +++ b/storage-interfaces/file-system/pallet-registry/src/benchmarking.rs @@ -107,7 +107,7 @@ fn make_primary_terms(owner: &T::AccountId, nonce: u64) -> AgreementT max_bytes: 1_000u64, duration: 100u32.into(), price_per_byte: 1u32.into(), - valid_until: pallet_storage_provider::Pallet::::current_block() + valid_until: pallet_storage_provider::Pallet::::current_anchor_block() .saturating_add(::RequestTimeout::get()), nonce, bucket_id: None, diff --git a/storage-interfaces/file-system/pallet-registry/src/lib.rs b/storage-interfaces/file-system/pallet-registry/src/lib.rs index 5794af93..afe2aba3 100644 --- a/storage-interfaces/file-system/pallet-registry/src/lib.rs +++ b/storage-interfaces/file-system/pallet-registry/src/lib.rs @@ -249,7 +249,7 @@ pub mod pallet { let next_id = drive_id.checked_add(1).ok_or(Error::::DriveIdOverflow)?; // Calculate expiry block - let current_block = pallet_storage_provider::Pallet::::current_block(); + let current_block = pallet_storage_provider::Pallet::::current_anchor_block(); let expires_at = current_block + storage_period; // Create drive info diff --git a/storage-interfaces/s3/pallet-s3-registry/src/benchmarking.rs b/storage-interfaces/s3/pallet-s3-registry/src/benchmarking.rs index 2c890862..c1d03a89 100644 --- a/storage-interfaces/s3/pallet-s3-registry/src/benchmarking.rs +++ b/storage-interfaces/s3/pallet-s3-registry/src/benchmarking.rs @@ -182,7 +182,7 @@ fn insert_s3_bucket( name: bounded_name.clone(), layer0_bucket_id: 0, owner: owner.clone(), - created_at: pallet_storage_provider::Pallet::::current_block(), + created_at: pallet_storage_provider::Pallet::::current_anchor_block(), object_count, total_size: 0, }; @@ -230,7 +230,7 @@ mod benchmarks { max_bytes, duration, price_per_byte: 1u32.into(), - valid_until: pallet_storage_provider::Pallet::::current_block() + valid_until: pallet_storage_provider::Pallet::::current_anchor_block() .saturating_add(::RequestTimeout::get()), nonce: 1, bucket_id: None, diff --git a/storage-interfaces/s3/pallet-s3-registry/src/lib.rs b/storage-interfaces/s3/pallet-s3-registry/src/lib.rs index bccb4f1d..c675dd62 100644 --- a/storage-interfaces/s3/pallet-s3-registry/src/lib.rs +++ b/storage-interfaces/s3/pallet-s3-registry/src/lib.rs @@ -242,7 +242,7 @@ pub mod pallet { name: bounded_name.clone(), layer0_bucket_id, owner: who.clone(), - created_at: pallet_storage_provider::Pallet::::current_block(), + created_at: pallet_storage_provider::Pallet::::current_anchor_block(), object_count: 0, total_size: 0, }; @@ -347,8 +347,8 @@ pub mod pallet { .unwrap_or_default(); // Get current timestamp - let timestamp = - pallet_storage_provider::Pallet::::current_block().saturated_into::(); + let timestamp = pallet_storage_provider::Pallet::::current_anchor_block() + .saturated_into::(); // Check if this is an update or new object match Objects::::get(s3_bucket_id, &bounded_key) { @@ -467,8 +467,8 @@ pub mod pallet { .ok_or(Error::::ObjectNotFound)?; // Update last modified - metadata.last_modified = - pallet_storage_provider::Pallet::::current_block().saturated_into::(); + metadata.last_modified = pallet_storage_provider::Pallet::::current_anchor_block() + .saturated_into::(); // Update destination bucket stats (re-read if same bucket since src was read separately) let mut dst_bucket = From 357b0bb62b41ae7f4ec78450512ae88b766d3840 Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Fri, 17 Jul 2026 13:17:25 +0200 Subject: [PATCH 15/20] Fix challenger checkpoint age to use the anchor clock analyze_provider compared the parachain head against the relay-denominated snapshot.checkpoint_block, so on live networks the age saturated to 0 and every provider read as freshly checkpointed. Re-add the SDK-side anchor helper (dropped with the subxt 0.50 signatures) as a runtime-API read and measure the age on it, reusing the block-pinned handle for a consistent snapshot. --- client/src/challenger.rs | 12 +++++------- client/src/substrate.rs | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/client/src/challenger.rs b/client/src/challenger.rs index 97b6a7f6..64d3e7d8 100644 --- a/client/src/challenger.rs +++ b/client/src/challenger.rs @@ -9,7 +9,7 @@ //! - Automated challenge strategies use crate::base::{BaseClient, ClientConfig, ClientError, ClientResult}; -use crate::substrate::{extrinsics, storage, SubstrateClient}; +use crate::substrate::{extrinsics, fetch_current_anchor_block, storage, SubstrateClient}; use sp_runtime::AccountId32; use storage_primitives::{BucketId, ChunkLocation, Commitment}; use subxt::dynamic::At; @@ -382,12 +382,10 @@ impl ChallengerClient { // Compute checkpoint age from bucket snapshot let last_checkpoint_age = { - let current_block = chain - .api() - .at_current_block() - .await - .map_err(|e| ClientError::Chain(format!("Failed to get latest block: {e}")))? - .block_number() as u32; + // `checkpoint_block` is on the pallet's anchor clock (relay + // blocks), so measure the age on that clock, not the parachain + // height. + let current_block = fetch_current_anchor_block(&at).await?; let (addr, keys) = storage::bucket_info(bucket_id); let bucket_thunk = at diff --git a/client/src/substrate.rs b/client/src/substrate.rs index 53e2c370..48ae43bb 100644 --- a/client/src/substrate.rs +++ b/client/src/substrate.rs @@ -862,6 +862,30 @@ pub mod storage { // Helper functions for common operations +/// The pallet's anchor block — the clock every on-chain duration is measured +/// against — via the `StorageProviderApi::current_anchor_block` runtime API. +/// Compare anchor-denominated values (deadlines, expiries, `checkpoint_block`) +/// against this, never the parachain height. +pub async fn fetch_current_anchor_block( + at: &subxt::client::ClientAtBlock, +) -> Result +where + C: subxt::client::OnlineClientAtBlockT, +{ + use codec::Decode; + // Raw `state_call` + manual decode so the API needn't be in the node's + // metadata snapshot. + let bytes = at + .runtime_apis() + .call_raw("StorageProviderApi_current_anchor_block", None) + .await + .map_err(|e| { + ClientError::Chain(format!("current_anchor_block runtime API call failed: {e}")) + })?; + u32::decode(&mut &bytes[..]) + .map_err(|e| ClientError::Chain(format!("Failed to decode anchor block: {e}"))) +} + /// Parse a hex string to H256. pub fn parse_h256(hex: &str) -> Result { let hex = hex.strip_prefix("0x").unwrap_or(hex); From ecbceb0efe782c7bcb8515887003db924f318a5f Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Fri, 17 Jul 2026 14:06:30 +0200 Subject: [PATCH 16/20] Rename anchor-clock locals from current_block to anchor_block Locals holding Pallet::current_anchor_block() were still named current_block, reading as parachain height. Rename them (and the checkpoint helpers' parameters) to anchor_block, matching the negotiate handler's naming; fix the stale Pallet::current_block doc link and the ChainStateNotReady messages along the way. --- client/src/challenger.rs | 4 +- pallet/src/benchmarking.rs | 14 ++-- pallet/src/impls/agreements.rs | 22 +++--- pallet/src/impls/buckets.rs | 4 +- pallet/src/impls/challenges.rs | 4 +- pallet/src/impls/checkpoints.rs | 10 +-- pallet/src/impls/providers.rs | 4 +- pallet/src/impls/signatures.rs | 10 +-- pallet/src/lib.rs | 78 +++++++++---------- pallet/src/mock.rs | 6 +- provider-node/src/checkpoint_coordinator.rs | 6 +- provider-node/src/error.rs | 6 +- provider-node/src/replica_sync_coordinator.rs | 4 +- .../file-system/pallet-registry/src/lib.rs | 6 +- 14 files changed, 90 insertions(+), 88 deletions(-) diff --git a/client/src/challenger.rs b/client/src/challenger.rs index 64d3e7d8..23b24f7b 100644 --- a/client/src/challenger.rs +++ b/client/src/challenger.rs @@ -385,7 +385,7 @@ impl ChallengerClient { // `checkpoint_block` is on the pallet's anchor clock (relay // blocks), so measure the age on that clock, not the parachain // height. - let current_block = fetch_current_anchor_block(&at).await?; + let anchor_block = fetch_current_anchor_block(&at).await?; let (addr, keys) = storage::bucket_info(bucket_id); let bucket_thunk = at @@ -411,7 +411,7 @@ impl ChallengerClient { .and_then(|v| v.as_u128()) .unwrap_or(0) as u32; - current_block.saturating_sub(checkpoint_block) + anchor_block.saturating_sub(checkpoint_block) } else { 0 } diff --git a/pallet/src/benchmarking.rs b/pallet/src/benchmarking.rs index 45966877..50e5e2e2 100644 --- a/pallet/src/benchmarking.rs +++ b/pallet/src/benchmarking.rs @@ -187,9 +187,9 @@ fn add_primary_to_bucket( bucket_id: BucketId, max_bytes: u64, ) { - let current_block = StorageProvider::::current_anchor_block(); + let anchor_block = StorageProvider::::current_anchor_block(); let duration: BlockNumberFor = 100u32.into(); - let expires_at = current_block.saturating_add(duration); + let expires_at = anchor_block.saturating_add(duration); Buckets::::mutate(bucket_id, |maybe| { if let Some(b) = maybe { @@ -205,7 +205,7 @@ fn add_primary_to_bucket( expires_at, extensions_blocked: false, role: ProviderRole::Primary, - started_at: current_block, + started_at: anchor_block, }; StorageAgreements::::insert(bucket_id, provider, agreement); @@ -613,11 +613,11 @@ mod benchmarks { // Advance block past agreement expiry + settlement timeout let agreement = StorageProvider::::storage_agreements(bucket_id, &provider).unwrap(); - let current_block = StorageProvider::::current_anchor_block(); + let anchor_block = StorageProvider::::current_anchor_block(); let target_block: BlockNumberFor = agreement .expires_at .saturating_add(T::SettlementTimeout::get()) - .saturating_add(current_block) + .saturating_add(anchor_block) .saturating_add(1u32.into()); set_block_number::(target_block); @@ -843,7 +843,7 @@ mod benchmarks { let provider = create_provider::(0); let bucket_id = setup_primary_agreement::(&admin, &provider, 0); - // Report window 1 — must satisfy current_block > window_start_block(window+1, interval). + // Report window 1 — must satisfy anchor_block > window_start_block(window+1, interval). // Target: interval * (window + 1) + 1 let window = 1u64; let interval = T::DefaultCheckpointInterval::get(); @@ -1300,7 +1300,7 @@ mod benchmarks { // Drive the real sweep over exactly one key. Anchor the cursor one // below `deadline`, then set the relay clock so the sweepable range // (keys < previous relay parent) is exactly `{deadline}`: - // `sweepable = current_block() - 1 = deadline`, `end = deadline`. + // `sweepable = current_anchor_block() - 1 = deadline`, `end = deadline`. LastSweptChallengeBlock::::put(deadline.saturating_sub(1u32.into())); let now = deadline.saturating_add(1u32.into()); set_block_number::(now); diff --git a/pallet/src/impls/agreements.rs b/pallet/src/impls/agreements.rs index 24d31e0d..0b16d0da 100644 --- a/pallet/src/impls/agreements.rs +++ b/pallet/src/impls/agreements.rs @@ -166,11 +166,11 @@ impl Pallet { ensure!(terms.max_bytes > 0, Error::::InvalidMaxBytesRequest); // Quote must not be stale and must not exceed the chain-enforced window. - // `terms.valid_until` must in range [current_block, current_block + RequestTimeout] - let current_block = Self::current_anchor_block(); - ensure!(terms.valid_until >= current_block, Error::::TermsExpired); + // `terms.valid_until` must in range [anchor_block, anchor_block + RequestTimeout] + let anchor_block = Self::current_anchor_block(); + ensure!(terms.valid_until >= anchor_block, Error::::TermsExpired); ensure!( - terms.valid_until <= current_block.saturating_add(T::RequestTimeout::get()), + terms.valid_until <= anchor_block.saturating_add(T::RequestTimeout::get()), Error::::TermsValidityTooLong ); @@ -233,7 +233,7 @@ impl Pallet { // `BucketCreated` for us. let bucket_id = Self::create_bucket_internal(owner, 1, Some(provider))?; - let expires_at = current_block.saturating_add(terms.duration); + let expires_at = anchor_block.saturating_add(terms.duration); let agreement = StorageAgreement { owner: owner.clone(), max_bytes: terms.max_bytes, @@ -242,7 +242,7 @@ impl Pallet { expires_at, extensions_blocked: false, role: ProviderRole::Primary, - started_at: current_block, + started_at: anchor_block, }; Providers::::mutate(provider, |maybe_provider| { @@ -297,10 +297,10 @@ impl Pallet { ensure!(terms.max_bytes > 0, Error::::InvalidMaxBytesRequest); // Quote must not be stale and must not exceed the chain-enforced window. - let current_block = Self::current_anchor_block(); - ensure!(terms.valid_until >= current_block, Error::::TermsExpired); + let anchor_block = Self::current_anchor_block(); + ensure!(terms.valid_until >= anchor_block, Error::::TermsExpired); ensure!( - terms.valid_until <= current_block.saturating_add(T::RequestTimeout::get()), + terms.valid_until <= anchor_block.saturating_add(T::RequestTimeout::get()), Error::::TermsValidityTooLong ); @@ -381,7 +381,7 @@ impl Pallet { .ok_or(Error::::ArithmeticOverflow)?; T::Currency::reserve(owner, total_lock)?; - let expires_at = current_block.saturating_add(terms.duration); + let expires_at = anchor_block.saturating_add(terms.duration); let agreement = StorageAgreement { owner: owner.clone(), max_bytes: terms.max_bytes, @@ -395,7 +395,7 @@ impl Pallet { min_sync_interval: replica_terms.min_sync_interval, last_sync: None, }, - started_at: current_block, + started_at: anchor_block, }; Providers::::mutate(provider, |maybe_provider| { diff --git a/pallet/src/impls/buckets.rs b/pallet/src/impls/buckets.rs index 1cd6f4c0..82a8eb62 100644 --- a/pallet/src/impls/buckets.rs +++ b/pallet/src/impls/buckets.rs @@ -44,8 +44,8 @@ impl Pallet { for (provider, agreement) in agreements { // Calculate prorated refund based on remaining time - let current_block = Self::current_anchor_block(); - let remaining_blocks = agreement.expires_at.saturating_sub(current_block); + let anchor_block = Self::current_anchor_block(); + let remaining_blocks = agreement.expires_at.saturating_sub(anchor_block); // If there's remaining time, calculate prorated refund let refund_to_owner = if remaining_blocks > Zero::zero() { diff --git a/pallet/src/impls/challenges.rs b/pallet/src/impls/challenges.rs index 12e40e9f..a1db8615 100644 --- a/pallet/src/impls/challenges.rs +++ b/pallet/src/impls/challenges.rs @@ -28,8 +28,8 @@ impl Pallet { T::Currency::reserve(&challenger, deposit)?; - let current_block = Self::current_anchor_block(); - let deadline = current_block.saturating_add(T::ChallengeTimeout::get()); + let anchor_block = Self::current_anchor_block(); + let deadline = anchor_block.saturating_add(T::ChallengeTimeout::get()); let challenge = Challenge { bucket_id, diff --git a/pallet/src/impls/checkpoints.rs b/pallet/src/impls/checkpoints.rs index 90692cc7..95772637 100644 --- a/pallet/src/impls/checkpoints.rs +++ b/pallet/src/impls/checkpoints.rs @@ -10,10 +10,10 @@ use storage_primitives::{BucketId, HISTORICAL_ROOT_PRIMES}; impl Pallet { pub(crate) fn update_historical_roots( bucket: &mut Bucket, - current_block: BlockNumberFor, + anchor_block: BlockNumberFor, mmr_root: H256, ) { - let block_num: u32 = current_block.try_into().unwrap_or(0u32); + let block_num: u32 = anchor_block.try_into().unwrap_or(0u32); for (i, &prime) in HISTORICAL_ROOT_PRIMES.iter().enumerate() { let quotient = block_num / prime; @@ -103,14 +103,14 @@ impl Pallet { }) } - /// Check if the current block is within the grace period for a window. + /// Check if the anchor block is within the grace period for a window. pub(crate) fn is_within_grace_period( - current_block: BlockNumberFor, + anchor_block: BlockNumberFor, window: u64, config: &storage_primitives::CheckpointWindowConfig>, ) -> bool { let window_start = Self::window_start_block(window, config.interval); let grace_end = window_start.saturating_add(config.grace_period); - current_block <= grace_end + anchor_block <= grace_end } } diff --git a/pallet/src/impls/providers.rs b/pallet/src/impls/providers.rs index a2101960..3dbd0aec 100644 --- a/pallet/src/impls/providers.rs +++ b/pallet/src/impls/providers.rs @@ -86,7 +86,7 @@ impl Pallet { // Reserve stake T::Currency::reserve(who, stake)?; - let current_block = Self::current_anchor_block(); + let anchor_block = Self::current_anchor_block(); let provider_info = ProviderInfo { multiaddr, @@ -95,7 +95,7 @@ impl Pallet { committed_bytes: 0, settings, stats: ProviderStats { - registered_at: current_block, + registered_at: anchor_block, ..Default::default() }, deregister_at: None, diff --git a/pallet/src/impls/signatures.rs b/pallet/src/impls/signatures.rs index 1561b933..9d4c7d86 100644 --- a/pallet/src/impls/signatures.rs +++ b/pallet/src/impls/signatures.rs @@ -14,16 +14,16 @@ impl Pallet { /// /// Returns Error::InvalidSignature if verification fails. /// Reject a `CommitmentPayload::nonce` that is too far behind (or ahead - /// of) the current block. This prevents an attacker who captures one + /// of) the anchor block. This prevents an attacker who captures one /// signed commitment from replaying it forever. pub(crate) fn ensure_recent_nonce(nonce: u64) -> DispatchResult { - let current: u64 = Self::current_anchor_block().saturated_into(); + let anchor_block: u64 = Self::current_anchor_block().saturated_into(); let max_age: u64 = T::MaxNonceAge::get().saturated_into(); // Future-dated nonces are nonsensical — the signer can only know - // the current block at sign-time. Allow exact equality. - ensure!(nonce <= current, Error::::CommitmentNonceTooOld); + // the anchor block at sign-time. Allow exact equality. + ensure!(nonce <= anchor_block, Error::::CommitmentNonceTooOld); ensure!( - current.saturating_sub(nonce) <= max_age, + anchor_block.saturating_sub(nonce) <= max_age, Error::::CommitmentNonceTooOld ); Ok(()) diff --git a/pallet/src/lib.rs b/pallet/src/lib.rs index 76f4835f..b7837f1b 100644 --- a/pallet/src/lib.rs +++ b/pallet/src/lib.rs @@ -110,7 +110,7 @@ pub mod pallet { /// [`LastSweptChallengeBlock`] rather than probing the single key `n`. /// /// - **Which keys are final.** In `on_initialize` the validation-data - /// inherent has not run, so [`Pallet::current_block`] is the relay + /// inherent has not run, so [`Pallet::current_anchor_block`] is the relay /// parent `p` of the *previous* parachain block. A challenge with /// deadline `d` stays respondable while some block has relay parent /// `<= d`; every future block has relay parent `>= p`; so keys `< p` @@ -1262,9 +1262,9 @@ pub mod pallet { #[pallet::weight(T::WeightInfo::deregister_provider())] pub fn deregister_provider(origin: OriginFor) -> DispatchResult { let who = ensure_signed(origin)?; - let current_block = Self::current_anchor_block(); + let anchor_block = Self::current_anchor_block(); let complete_after = - current_block.saturating_add(T::DeregisterAnnouncementPeriod::get()); + anchor_block.saturating_add(T::DeregisterAnnouncementPeriod::get()); Providers::::try_mutate(&who, |maybe_provider| -> DispatchResult { let provider = maybe_provider @@ -1315,9 +1315,9 @@ pub mod pallet { let deregister_at = provider .deregister_at .ok_or(Error::::DeregisterNotAnnounced)?; - let current_block = Self::current_anchor_block(); + let anchor_block = Self::current_anchor_block(); ensure!( - current_block >= deregister_at, + anchor_block >= deregister_at, Error::::DeregisterPeriodNotElapsed ); ensure!( @@ -1471,7 +1471,7 @@ pub mod pallet { Error::::ProviderNotFound ); - let current_block = Self::current_anchor_block(); + let anchor_block = Self::current_anchor_block(); StorageAgreements::::try_mutate( bucket_id, @@ -1481,7 +1481,7 @@ pub mod pallet { .as_mut() .ok_or(Error::::AgreementNotFound)?; ensure!( - current_block < agreement.expires_at, + anchor_block < agreement.expires_at, Error::::AgreementExpired ); agreement.extensions_blocked = blocked; @@ -1799,9 +1799,9 @@ pub mod pallet { Error::::AgreementHasPendingChallenge ); - let current_block = Self::current_anchor_block(); + let anchor_block = Self::current_anchor_block(); - let is_early_termination = current_block < agreement.expires_at; + let is_early_termination = anchor_block < agreement.expires_at; if is_early_termination { // Only admin can early-terminate, and only primaries @@ -1820,7 +1820,7 @@ pub mod pallet { .expires_at .saturating_add(T::SettlementTimeout::get()); ensure!( - current_block <= settlement_deadline, + anchor_block <= settlement_deadline, Error::::SettlementWindowPassed ); } @@ -1854,10 +1854,10 @@ pub mod pallet { Error::::AgreementHasPendingChallenge ); - let current_block = Self::current_anchor_block(); + let anchor_block = Self::current_anchor_block(); ensure!( - current_block > agreement.expires_at, + anchor_block > agreement.expires_at, Error::::AgreementNotExpired ); @@ -1865,7 +1865,7 @@ pub mod pallet { .expires_at .saturating_add(T::SettlementTimeout::get()); ensure!( - current_block > settlement_deadline, + anchor_block > settlement_deadline, Error::::AgreementNotExpired ); @@ -1902,9 +1902,9 @@ pub mod pallet { ensure!(agreement.owner == who, Error::::NotAgreementOwner); - let current_block = Self::current_anchor_block(); - let remaining_duration = if current_block < agreement.expires_at { - agreement.expires_at.saturating_sub(current_block) + let anchor_block = Self::current_anchor_block(); + let remaining_duration = if anchor_block < agreement.expires_at { + agreement.expires_at.saturating_sub(anchor_block) } else { return Err(Error::::AgreementExpired.into()); }; @@ -2006,7 +2006,7 @@ pub mod pallet { // Validate duration Self::validate_duration(&provider_info.settings, additional_duration)?; - let current_block = Self::current_anchor_block(); + let anchor_block = Self::current_anchor_block(); // Check if price increased let price_increased = @@ -2019,9 +2019,9 @@ pub mod pallet { // If price same or decreased, anyone can extend (permissionless persistence) // Settle current period - let elapsed = current_block.saturating_sub(agreement.started_at); - let _remaining = if current_block < agreement.expires_at { - agreement.expires_at.saturating_sub(current_block) + let elapsed = anchor_block.saturating_sub(agreement.started_at); + let _remaining = if anchor_block < agreement.expires_at { + agreement.expires_at.saturating_sub(anchor_block) } else { Zero::zero() }; @@ -2064,8 +2064,8 @@ pub mod pallet { T::Currency::reserve(&who, extension_payment)?; // Update agreement - agreement.expires_at = current_block.saturating_add(additional_duration); - agreement.started_at = current_block; + agreement.expires_at = anchor_block.saturating_add(additional_duration); + agreement.started_at = anchor_block; agreement.price_per_byte = provider_info.settings.price_per_byte; // For replicas, also update sync_price and handle sync_balance @@ -2180,14 +2180,14 @@ pub mod pallet { Error::::InsufficientSignatures ); - let current_block = Self::current_anchor_block(); + let anchor_block = Self::current_anchor_block(); // Update historical roots - Self::update_historical_roots(bucket, current_block, commitment.mmr_root); + Self::update_historical_roots(bucket, anchor_block, commitment.mmr_root); bucket.snapshot = Some(BucketSnapshot { commitment, - checkpoint_block: current_block, + checkpoint_block: anchor_block, primary_signers, commitment_nonce: nonce, }); @@ -2324,8 +2324,8 @@ pub mod pallet { ensure!(config.enabled, Error::::ProviderCheckpointsDisabled); // Get current block and calculate current window - let current_block = Self::current_anchor_block(); - let current_window = Self::calculate_window(current_block, config.interval); + let anchor_block = Self::current_anchor_block(); + let current_window = Self::calculate_window(anchor_block, config.interval); // Validate window ensure!( @@ -2352,7 +2352,7 @@ pub mod pallet { .ok_or(Error::::ProviderNotInSnapshot)?; // Check caller authorization - let within_grace = Self::is_within_grace_period(current_block, window, &config); + let within_grace = Self::is_within_grace_period(anchor_block, window, &config); if within_grace { // Only leader can submit during grace period ensure!(&who == expected_leader, Error::::NotCheckpointLeader); @@ -2410,7 +2410,7 @@ pub mod pallet { ); // Update historical roots - Self::update_historical_roots(bucket, current_block, mmr_root); + Self::update_historical_roots(bucket, anchor_block, mmr_root); // Update bucket snapshot. // @@ -2424,7 +2424,7 @@ pub mod pallet { // two schemes. bucket.snapshot = Some(BucketSnapshot { commitment, - checkpoint_block: current_block, + checkpoint_block: anchor_block, primary_signers, commitment_nonce: 0, }); @@ -2521,8 +2521,8 @@ pub mod pallet { ensure!(config.enabled, Error::::ProviderCheckpointsDisabled); // Get current window - let current_block = Self::current_anchor_block(); - let current_window = Self::calculate_window(current_block, config.interval); + let anchor_block = Self::current_anchor_block(); + let current_window = Self::calculate_window(anchor_block, config.interval); // Can only report past windows ensure!(window < current_window, Error::::InvalidCheckpointWindow); @@ -2534,7 +2534,7 @@ pub mod pallet { // Ensure we're past the grace period of the reported window let window_end = Self::window_start_block(window.saturating_add(1), config.interval); - ensure!(current_block > window_end, Error::::WithinGracePeriod); + ensure!(anchor_block > window_end, Error::::WithinGracePeriod); // Calculate leader for the missed window let num_providers = bucket.primary_providers.len() as u32; @@ -2854,9 +2854,9 @@ pub mod pallet { ensure!(challenge.provider == who, Error::::NotChallengeProvider); - let current_block = Self::current_anchor_block(); + let anchor_block = Self::current_anchor_block(); ensure!( - current_block <= challenge_id.deadline, + anchor_block <= challenge_id.deadline, Error::::ChallengeExpired ); @@ -2977,7 +2977,7 @@ pub mod pallet { let challenge_created_at = challenge_id .deadline .saturating_sub(T::ChallengeTimeout::get()); - let response_time = current_block.saturating_sub(challenge_created_at); + let response_time = anchor_block.saturating_sub(challenge_created_at); // Calculate cost split based on response time // Per design: @@ -3093,12 +3093,12 @@ pub mod pallet { ProviderRole::Primary => return Err(Error::::NotReplica.into()), }; - let current_block = Self::current_anchor_block(); + let anchor_block = Self::current_anchor_block(); // Check sync interval if let Some(record) = last_sync { let min_next_block = record.block.saturating_add(*min_sync_interval); - ensure!(current_block >= min_next_block, Error::::SyncTooFrequent); + ensure!(anchor_block >= min_next_block, Error::::SyncTooFrequent); } // Find matching root position @@ -3146,7 +3146,7 @@ pub mod pallet { start_seq, leaf_count, }, - block: current_block, + block: anchor_block, }); // Transfer sync payment to provider diff --git a/pallet/src/mock.rs b/pallet/src/mock.rs index 0772d2b3..b23cdf7c 100644 --- a/pallet/src/mock.rs +++ b/pallet/src/mock.rs @@ -387,7 +387,7 @@ pub fn setup_replica_agreement( /// a fresh single-primary bucket, so multi-primary shapes are synthesized. #[allow(dead_code)] pub fn add_primary_to_bucket(provider: u64, owner: u64, bucket_id: u64, max_bytes: u64) { - let current_block = System::block_number(); + let anchor_block = System::block_number(); crate::Buckets::::mutate(bucket_id, |maybe_bucket| { if let Some(bucket) = maybe_bucket { let _ = bucket.primary_providers.try_push(provider); @@ -401,10 +401,10 @@ pub fn add_primary_to_bucket(provider: u64, owner: u64, bucket_id: u64, max_byte max_bytes, payment_locked: 0, price_per_byte: 0, - expires_at: current_block + 200, + expires_at: anchor_block + 200, extensions_blocked: false, role: storage_primitives::ProviderRole::Primary, - started_at: current_block, + started_at: anchor_block, }, ); crate::Providers::::mutate(provider, |maybe_p| { diff --git a/provider-node/src/checkpoint_coordinator.rs b/provider-node/src/checkpoint_coordinator.rs index 15965927..45406f21 100644 --- a/provider-node/src/checkpoint_coordinator.rs +++ b/provider-node/src/checkpoint_coordinator.rs @@ -355,7 +355,7 @@ impl CheckpointCoordinator { return Ok(None); } - let current_block = self.chain_client.get_current_block().await?; + let anchor_block = self.chain_client.get_current_block().await?; let (interval, grace_period) = self .chain_client @@ -364,7 +364,7 @@ impl CheckpointCoordinator { .unwrap_or((100u32, 20u32)); let window = if interval > 0 { - current_block / interval as u64 + anchor_block / interval as u64 } else { 0 }; @@ -372,7 +372,7 @@ impl CheckpointCoordinator { tracing::info!( "Checkpoint duty: bucket={} block={} interval={} window={} mmr_root=0x{} leaves={}", bucket_id, - current_block, + anchor_block, interval, window, hex::encode(&bucket.mmr_root.as_bytes()[..4]), diff --git a/provider-node/src/error.rs b/provider-node/src/error.rs index a3295993..b9277768 100644 --- a/provider-node/src/error.rs +++ b/provider-node/src/error.rs @@ -103,7 +103,9 @@ pub enum Error { #[error("Provider is deregistering; not accepting new agreements")] ProviderDeregistering, - #[error("Chain state not ready: current_block and request_timeout must both be non-zero")] + #[error( + "Chain state not ready: current_anchor_block and request_timeout must both be non-zero" + )] ChainStateNotReady, #[error("Storage agreement requested 0 byte")] @@ -338,7 +340,7 @@ impl IntoResponse for Error { ErrorResponse { error: "chain_state_not_ready".to_string(), details: Some(serde_json::json!({ - "message": "current_block or request_timeout is 0; \ + "message": "current_anchor_block or request_timeout is 0; \ the node has not yet synced with the chain" })), }, diff --git a/provider-node/src/replica_sync_coordinator.rs b/provider-node/src/replica_sync_coordinator.rs index fe4be14a..a609ffa8 100644 --- a/provider-node/src/replica_sync_coordinator.rs +++ b/provider-node/src/replica_sync_coordinator.rs @@ -430,7 +430,7 @@ impl ReplicaSyncCoordinator { pub async fn get_active_replica_duties(&self) -> Result, Error> { let mut duties = Vec::new(); - let current_block = self.chain_client.get_current_block().await?; + let anchor_block = self.chain_client.get_current_block().await?; let local_buckets: Vec = self .state @@ -459,7 +459,7 @@ impl ReplicaSyncCoordinator { } if let Some((_, last_block)) = agreement.last_sync { - let elapsed = current_block.saturating_sub(last_block); + let elapsed = anchor_block.saturating_sub(last_block); if elapsed < agreement.min_sync_interval { tracing::debug!( "Bucket {} sync interval not elapsed: {} < {}", diff --git a/storage-interfaces/file-system/pallet-registry/src/lib.rs b/storage-interfaces/file-system/pallet-registry/src/lib.rs index afe2aba3..1ffe428d 100644 --- a/storage-interfaces/file-system/pallet-registry/src/lib.rs +++ b/storage-interfaces/file-system/pallet-registry/src/lib.rs @@ -249,14 +249,14 @@ pub mod pallet { let next_id = drive_id.checked_add(1).ok_or(Error::::DriveIdOverflow)?; // Calculate expiry block - let current_block = pallet_storage_provider::Pallet::::current_anchor_block(); - let expires_at = current_block + storage_period; + let anchor_block = pallet_storage_provider::Pallet::::current_anchor_block(); + let expires_at = anchor_block + storage_period; // Create drive info let drive_info = DriveInfo { owner: who.clone(), bucket_id, - created_at: current_block, + created_at: anchor_block, name: bounded_name, max_capacity, storage_period, From 4abe26d45a6d1d5734b083c317c45fca47110ed6 Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Fri, 17 Jul 2026 14:26:40 +0200 Subject: [PATCH 17/20] Expose anchor_block_time_millis runtime API; format durations on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit current_anchor_block tells clients where the pallet clock is but not how fast it ticks, so the provider dashboard humanized anchor-denominated durations (agreement duration, checkpoint interval/grace, min/max duration) with Aura.SlotDuration — identical today, silently wrong once the parachain block time changes. Add the paired runtime API (both runtimes return RELAY_CHAIN_SLOT_DURATION_MILLIS) and read it in the dashboard via the unsafe API (no descriptor regeneration; older runtimes keep the 6s default), renaming blockTimeMs to anchorBlockTimeMs so parachain block time can't be rewired in by accident. --- pallet/src/runtime_api.rs | 5 +++++ runtimes/web3-storage-local/src/lib.rs | 6 ++++++ runtimes/web3-storage-paseo/src/lib.rs | 6 ++++++ .../provider/src/lib/chain-client.ts | 18 +++++++++++++----- user-interfaces/provider/src/utils/format.ts | 11 +++++++---- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/pallet/src/runtime_api.rs b/pallet/src/runtime_api.rs index 2c50e369..58d7a7df 100644 --- a/pallet/src/runtime_api.rs +++ b/pallet/src/runtime_api.rs @@ -197,5 +197,10 @@ sp_api::decl_runtime_apis! { /// this instead of a specific storage item so they need not know whether /// the anchor is a relay, parachain, or other block number. fn current_anchor_block() -> BlockNumber; + + /// Milliseconds per anchor block. Pairs with `current_anchor_block` so + /// off-chain consumers can humanize anchor-denominated durations + /// without knowing which clock the pallet measures them on. + fn anchor_block_time_millis() -> u64; } } diff --git a/runtimes/web3-storage-local/src/lib.rs b/runtimes/web3-storage-local/src/lib.rs index 66527612..191feea5 100644 --- a/runtimes/web3-storage-local/src/lib.rs +++ b/runtimes/web3-storage-local/src/lib.rs @@ -837,6 +837,12 @@ pallet_revive::impl_runtime_apis_plus_revive_traits!( fn current_anchor_block() -> BlockNumber { StorageProvider::current_anchor_block() } + + fn anchor_block_time_millis() -> u64 { + // The anchor is the relay chain (`RelaychainDataProvider`), so one + // anchor block is one relay slot. + RELAY_CHAIN_SLOT_DURATION_MILLIS as u64 + } } impl xcm_runtime_apis::fees::XcmPaymentApi for Runtime { diff --git a/runtimes/web3-storage-paseo/src/lib.rs b/runtimes/web3-storage-paseo/src/lib.rs index 3cbdc207..1423de05 100644 --- a/runtimes/web3-storage-paseo/src/lib.rs +++ b/runtimes/web3-storage-paseo/src/lib.rs @@ -846,6 +846,12 @@ pallet_revive::impl_runtime_apis_plus_revive_traits!( fn current_anchor_block() -> BlockNumber { StorageProvider::current_anchor_block() } + + fn anchor_block_time_millis() -> u64 { + // The anchor is the relay chain (`RelaychainDataProvider`), so one + // anchor block is one relay slot. + RELAY_CHAIN_SLOT_DURATION_MILLIS as u64 + } } impl xcm_runtime_apis::fees::XcmPaymentApi for Runtime { diff --git a/user-interfaces/provider/src/lib/chain-client.ts b/user-interfaces/provider/src/lib/chain-client.ts index fb3c785d..b2ac9215 100644 --- a/user-interfaces/provider/src/lib/chain-client.ts +++ b/user-interfaces/provider/src/lib/chain-client.ts @@ -47,7 +47,7 @@ export function disconnectFromChain(): void { export async function getChainProperties(): Promise<{ tokenDecimals: number tokenSymbol: string - blockTimeMs: number + anchorBlockTimeMs: number minProviderStake: bigint ss58Prefix: number specName: string @@ -58,7 +58,7 @@ export async function getChainProperties(): Promise<{ // them via constants / spec data. let tokenDecimals = 12 let tokenSymbol = 'UNIT' - let blockTimeMs = 6000 + let anchorBlockTimeMs = 6000 let minProviderStake = 1_000_000_000_000_000n let ss58Prefix = getSs58Prefix() let specName = '' @@ -98,13 +98,21 @@ export async function getChainProperties(): Promise<{ minProviderStake = await api.constants.StorageProvider.MinProviderStake() } catch { /* use default */ } + // Anchor-clock tick — deliberately NOT Aura.SlotDuration: every duration + // this UI formats (agreement duration, checkpoint interval/grace, provider + // min/max duration) is anchor-denominated (relay blocks), which the + // parachain block time will stop matching when it changes. The unsafe API + // resolves against live metadata, so no descriptor regeneration is needed; + // runtimes without the API keep the 6s default. try { - const period = await api.constants.Aura.SlotDuration() - blockTimeMs = Number(period) + const millis = await client + .getUnsafeApi() + .apis.StorageProviderApi.anchor_block_time_millis() + anchorBlockTimeMs = Number(millis) } catch { /* use default */ } } - return { tokenDecimals, tokenSymbol, blockTimeMs, minProviderStake, ss58Prefix, specName, specVersion, genesisHash } + return { tokenDecimals, tokenSymbol, anchorBlockTimeMs, minProviderStake, ss58Prefix, specName, specVersion, genesisHash } } export async function getGenesisHash(): Promise { diff --git a/user-interfaces/provider/src/utils/format.ts b/user-interfaces/provider/src/utils/format.ts index 895cfaac..03bfd9b2 100644 --- a/user-interfaces/provider/src/utils/format.ts +++ b/user-interfaces/provider/src/utils/format.ts @@ -4,7 +4,9 @@ // metadata at connection time via configureFromChain(). let tokenDecimals = 12 let tokenSymbol = 'UNIT' -let blockTimeMs = 6000 +// Milliseconds per anchor block (the pallet clock all on-chain durations use), +// not the parachain block time. +let anchorBlockTimeMs = 6000 let UNIT = 10n ** BigInt(tokenDecimals) /** @@ -18,7 +20,7 @@ let UNIT = 10n ** BigInt(tokenDecimals) export async function configureFromChain(props: { tokenDecimals: number tokenSymbol: string - blockTimeMs: number + anchorBlockTimeMs: number ss58Prefix: number minProviderStake: bigint specName: string @@ -27,7 +29,7 @@ export async function configureFromChain(props: { }): Promise<{ name: string; version: string; genesisHash: string }> { tokenDecimals = props.tokenDecimals tokenSymbol = props.tokenSymbol - blockTimeMs = props.blockTimeMs + anchorBlockTimeMs = props.anchorBlockTimeMs UNIT = 10n ** BigInt(tokenDecimals) // Dynamically import to avoid circular dependency (wallet → chain-client → chain) @@ -125,11 +127,12 @@ export function formatBytes(bytes: number | bigint): string { return `${parseFloat((b / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` } +/** Humanize a duration given in anchor blocks (the pallet clock). */ export function formatDuration(blocks: number): string { if (blocks === 0) return '0 blocks' if (blocks >= 4_000_000_000) return 'no limit' - const seconds = blocks * (blockTimeMs / 1000) + const seconds = blocks * (anchorBlockTimeMs / 1000) const minutes = Math.floor(seconds / 60) const hours = Math.floor(minutes / 60) const days = Math.floor(hours / 24) From fcfd9fa830ff68351c27d3e3276c8b37ff65eb1f Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Fri, 17 Jul 2026 14:39:45 +0200 Subject: [PATCH 18/20] Move the anchor tick into the pallet as Config::AnchorBlockTimeMillis The anchor_block_time_millis runtime API hardcoded RELAY_CHAIN_SLOT_DURATION_MILLIS in each runtime's impl block, away from the BlockNumberProvider wiring it describes. Make it a #[pallet::constant] instead: the clock and its tick are now declared side by side in each runtime's storage.rs, the runtime API delegates to the pallet like current_anchor_block does, integrity_test rejects a zero value, and the constant lands in metadata for free. --- pallet/src/impls/queries.rs | 7 +++++++ pallet/src/lib.rs | 12 ++++++++++++ pallet/src/mock.rs | 1 + runtimes/web3-storage-local/src/lib.rs | 4 +--- runtimes/web3-storage-local/src/storage.rs | 8 +++++++- runtimes/web3-storage-paseo/src/lib.rs | 4 +--- runtimes/web3-storage-paseo/src/storage.rs | 9 ++++++++- .../file-system/pallet-registry/src/mock.rs | 1 + storage-interfaces/s3/pallet-s3-registry/src/mock.rs | 1 + 9 files changed, 39 insertions(+), 8 deletions(-) diff --git a/pallet/src/impls/queries.rs b/pallet/src/impls/queries.rs index 61649f61..f75ad85c 100644 --- a/pallet/src/impls/queries.rs +++ b/pallet/src/impls/queries.rs @@ -76,6 +76,13 @@ impl Pallet { ::current_block_number() } + /// Milliseconds per anchor block; pairs with + /// [`Self::current_anchor_block`] so off-chain consumers can humanize + /// anchor-denominated durations. + pub fn anchor_block_time_millis() -> u64 { + T::AnchorBlockTimeMillis::get() + } + /// Query provider information. pub fn query_provider_info( provider: &T::AccountId, diff --git a/pallet/src/lib.rs b/pallet/src/lib.rs index b7837f1b..a417807b 100644 --- a/pallet/src/lib.rs +++ b/pallet/src/lib.rs @@ -226,6 +226,11 @@ pub mod pallet { challenge created at the announcement block matures while the \ provider is still slashable" ); + assert!( + T::AnchorBlockTimeMillis::get() > 0, + "AnchorBlockTimeMillis must be non-zero; off-chain consumers \ + convert anchor-denominated durations to wall-clock time with it" + ); } #[cfg(feature = "try-runtime")] @@ -355,6 +360,13 @@ pub mod pallet { BlockNumber = BlockNumberFor, >; + /// Milliseconds per anchor block — the tick of + /// [`Config::BlockNumberProvider`]. Exposed via the + /// `anchor_block_time_millis` runtime API so off-chain consumers can + /// humanize anchor-denominated durations. + #[pallet::constant] + type AnchorBlockTimeMillis: Get; + /// Weight information for extrinsics in this pallet. type WeightInfo: WeightInfo; } diff --git a/pallet/src/mock.rs b/pallet/src/mock.rs index b23cdf7c..a777c6c1 100644 --- a/pallet/src/mock.rs +++ b/pallet/src/mock.rs @@ -104,6 +104,7 @@ impl pallet_storage_provider::Config for Test { // thousands of challenges. type MaxChallengesPerDeadline = ConstU16<5>; type BlockNumberProvider = System; + type AnchorBlockTimeMillis = ConstU64<6000>; type WeightInfo = (); } diff --git a/runtimes/web3-storage-local/src/lib.rs b/runtimes/web3-storage-local/src/lib.rs index 191feea5..b5296f5a 100644 --- a/runtimes/web3-storage-local/src/lib.rs +++ b/runtimes/web3-storage-local/src/lib.rs @@ -839,9 +839,7 @@ pallet_revive::impl_runtime_apis_plus_revive_traits!( } fn anchor_block_time_millis() -> u64 { - // The anchor is the relay chain (`RelaychainDataProvider`), so one - // anchor block is one relay slot. - RELAY_CHAIN_SLOT_DURATION_MILLIS as u64 + StorageProvider::anchor_block_time_millis() } } diff --git a/runtimes/web3-storage-local/src/storage.rs b/runtimes/web3-storage-local/src/storage.rs index 33930273..d4c3cda4 100644 --- a/runtimes/web3-storage-local/src/storage.rs +++ b/runtimes/web3-storage-local/src/storage.rs @@ -10,7 +10,9 @@ use frame_support::{ use sp_runtime::traits::AccountIdConversion; use crate::{ - constants::{currency::UNIT, relay_time::RC_HOURS}, + constants::{ + consensus::RELAY_CHAIN_SLOT_DURATION_MILLIS, currency::UNIT, relay_time::RC_HOURS, + }, AccountId, Balance, Balances, BlockNumber, Runtime, RuntimeEvent, }; @@ -50,6 +52,9 @@ parameter_types! { /// challenges created while the chain sits on the same relay parent /// share a deadline. pub const MaxChallengesPerDeadline: u16 = 1_000; + /// One anchor block = one relay slot: `BlockNumberProvider` below reads + /// the relay chain. + pub const AnchorBlockTimeMillis: u64 = RELAY_CHAIN_SLOT_DURATION_MILLIS as u64; } /// Treasury account that receives slashed funds. @@ -110,5 +115,6 @@ impl pallet_storage_provider::Config for Runtime { type DeregisterAnnouncementPeriod = DeregisterAnnouncementPeriod; type MaxChallengesPerDeadline = MaxChallengesPerDeadline; type BlockNumberProvider = cumulus_pallet_parachain_system::RelaychainDataProvider; + type AnchorBlockTimeMillis = AnchorBlockTimeMillis; type WeightInfo = crate::weights::pallet_storage_provider::WeightInfo; } diff --git a/runtimes/web3-storage-paseo/src/lib.rs b/runtimes/web3-storage-paseo/src/lib.rs index 1423de05..1a8e52d1 100644 --- a/runtimes/web3-storage-paseo/src/lib.rs +++ b/runtimes/web3-storage-paseo/src/lib.rs @@ -848,9 +848,7 @@ pallet_revive::impl_runtime_apis_plus_revive_traits!( } fn anchor_block_time_millis() -> u64 { - // The anchor is the relay chain (`RelaychainDataProvider`), so one - // anchor block is one relay slot. - RELAY_CHAIN_SLOT_DURATION_MILLIS as u64 + StorageProvider::anchor_block_time_millis() } } diff --git a/runtimes/web3-storage-paseo/src/storage.rs b/runtimes/web3-storage-paseo/src/storage.rs index 30692849..cc96f3e7 100644 --- a/runtimes/web3-storage-paseo/src/storage.rs +++ b/runtimes/web3-storage-paseo/src/storage.rs @@ -11,7 +11,9 @@ use frame_support::{ use sp_runtime::traits::AccountIdConversion; use crate::{ - paseo_constants::{currency::UNIT, relay_time::RC_HOURS}, + paseo_constants::{ + consensus::RELAY_CHAIN_SLOT_DURATION_MILLIS, currency::UNIT, relay_time::RC_HOURS, + }, AccountId, Balance, Balances, BlockNumber, Runtime, RuntimeEvent, }; @@ -57,6 +59,10 @@ parameter_types! { /// challenges created while the chain sits on the same relay parent /// share a deadline. pub storage MaxChallengesPerDeadline: u16 = 1_000; + /// One anchor block = one relay slot: `BlockNumberProvider` below reads + /// the relay chain. `const` (not `storage`): a physical property of the + /// anchor clock, not a tunable. + pub const AnchorBlockTimeMillis: u64 = RELAY_CHAIN_SLOT_DURATION_MILLIS as u64; } /// Treasury account that receives slashed funds. @@ -117,5 +123,6 @@ impl pallet_storage_provider::Config for Runtime { type DeregisterAnnouncementPeriod = DeregisterAnnouncementPeriod; type MaxChallengesPerDeadline = MaxChallengesPerDeadline; type BlockNumberProvider = cumulus_pallet_parachain_system::RelaychainDataProvider; + type AnchorBlockTimeMillis = AnchorBlockTimeMillis; type WeightInfo = crate::weights::pallet_storage_provider::WeightInfo; } diff --git a/storage-interfaces/file-system/pallet-registry/src/mock.rs b/storage-interfaces/file-system/pallet-registry/src/mock.rs index e200ae5d..eb3c2f92 100644 --- a/storage-interfaces/file-system/pallet-registry/src/mock.rs +++ b/storage-interfaces/file-system/pallet-registry/src/mock.rs @@ -116,6 +116,7 @@ impl pallet_storage_provider::Config for Test { type DeregisterAnnouncementPeriod = ConstU64<150>; type MaxChallengesPerDeadline = ConstU16<1_000>; type BlockNumberProvider = System; + type AnchorBlockTimeMillis = ConstU64<6000>; type WeightInfo = (); } diff --git a/storage-interfaces/s3/pallet-s3-registry/src/mock.rs b/storage-interfaces/s3/pallet-s3-registry/src/mock.rs index c7b233d7..4b700a59 100644 --- a/storage-interfaces/s3/pallet-s3-registry/src/mock.rs +++ b/storage-interfaces/s3/pallet-s3-registry/src/mock.rs @@ -116,6 +116,7 @@ impl pallet_storage_provider::Config for Test { type DeregisterAnnouncementPeriod = ConstU64<150>; type MaxChallengesPerDeadline = ConstU16<1_000>; type BlockNumberProvider = System; + type AnchorBlockTimeMillis = ConstU64<6000>; type WeightInfo = (); } From 859bd27655462eb9e0db70e99de3348f5e8a3d91 Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Fri, 17 Jul 2026 14:40:04 +0200 Subject: [PATCH 19/20] Track the pallet's anchor block in the UIs; fix six pallet-clock comparisons The provider dashboard and s3-ui compared the parachain height against anchor-denominated on-chain values, which silently passes on zombienet and breaks on any network where relay numbers dwarf parachain heights: agreement expiry and challenge status in chain-client.ts (whose local blockNumber$ was additionally never fed, so both always read 0), checkpoint-overdue detection, the deregister cooldown, earnings progress, and the s3-ui challenge countdown. Add an anchorBlock$ to each UI's chain state, refreshed per finalized block from the current_anchor_block runtime API via the unsafe API (live metadata, no descriptor regeneration; pre-anchor runtimes fall back to the parachain height, which is their pallet clock), and point all six comparisons at it. --- .../provider/src/lib/chain-client.ts | 39 ++++++++++++++++--- .../provider/src/pages/Earnings.tsx | 7 ++-- .../provider/src/pages/Overview.tsx | 9 +++-- .../provider/src/state/chain.state.ts | 22 ++++++++++- .../provider/src/state/provider.state.ts | 10 +++-- .../s3-ui/src/components/CheckpointPanel.tsx | 7 ++-- .../s3-ui/src/state/chain.state.ts | 16 ++++++++ user-interfaces/s3-ui/src/state/index.ts | 1 + 8 files changed, 90 insertions(+), 21 deletions(-) diff --git a/user-interfaces/provider/src/lib/chain-client.ts b/user-interfaces/provider/src/lib/chain-client.ts index b2ac9215..aa8d387b 100644 --- a/user-interfaces/provider/src/lib/chain-client.ts +++ b/user-interfaces/provider/src/lib/chain-client.ts @@ -33,11 +33,36 @@ export { clientReady$, connectToChain, getClient, subscribeToBlocks } // The generic connection lifecycle lives in the shared @web3-storage/chain-client // package (imported/re-exported above); only provider-specific state stays here. -export const blockNumber$ = new BehaviorSubject(undefined) +/** + * The pallet's anchor block — the clock every on-chain duration (agreement + * expiry, challenge deadlines, checkpoint windows, deregister cooldown) is + * measured against. NOT the parachain height: on live networks the two clocks + * differ by millions of blocks, so pallet-clock comparisons must read this. + */ +export const anchorBlock$ = new BehaviorSubject(undefined) + +/** + * Refresh [`anchorBlock$`] from the `current_anchor_block` runtime API. The + * unsafe API resolves against live metadata, so no descriptor regeneration is + * needed; on runtimes without the API the parachain height is the pallet + * clock, so fall back to it. + */ +export async function refreshAnchorBlock(parachainBlock: number): Promise { + let anchor = parachainBlock + const client = getClient() + if (client) { + try { + anchor = Number( + await client.getUnsafeApi().apis.StorageProviderApi.current_anchor_block() + ) + } catch { /* pre-anchor runtime */ } + } + anchorBlock$.next(anchor) +} export function disconnectFromChain(): void { disconnectChain() - blockNumber$.next(undefined) + anchorBlock$.next(undefined) } // ───────────────────────────────────────────────────────────────────────────── @@ -339,7 +364,8 @@ export async function getAccountBalance(address: string): Promise<{ export async function getProviderAgreements(address: string): Promise { const entries = await requireApi().query.StorageProvider.StorageAgreements.getEntries() - const currentBlock = blockNumber$.getValue() || 0 + // expires_at is on the pallet's anchor clock, so the comparison must be too. + const anchorBlock = anchorBlock$.getValue() || 0 const out: OnChainAgreement[] = [] for (const { keyArgs, value } of entries) { const [bucketIdRaw, providerAddr] = keyArgs @@ -348,7 +374,7 @@ export async function getProviderAgreements(address: string): Promise expiresAt && expiresAt > 0) status = 'expired' + else if (anchorBlock > expiresAt && expiresAt > 0) status = 'expired' out.push({ id: bucketId, bucketId, @@ -490,7 +516,8 @@ export async function getBucketDetails( export async function getProviderChallenges(address: string): Promise { const entries = await requireApi().query.StorageProvider.Challenges.getEntries() - const currentBlock = blockNumber$.getValue() || 0 + // Challenge deadlines are on the pallet's anchor clock. + const anchorBlock = anchorBlock$.getValue() || 0 const challenges: OnChainChallenge[] = [] // Challenges is a StorageDoubleMap keyed by (deadline, index): each entry is // a single challenge with keyArgs = [deadline, index] and value = Challenge. @@ -507,7 +534,7 @@ export async function getProviderChallenges(address: string): Promise deadline ? 'expired' : 'pending', + status: anchorBlock > deadline ? 'expired' : 'pending', createdAt: 0, deadline, }) diff --git a/user-interfaces/provider/src/pages/Earnings.tsx b/user-interfaces/provider/src/pages/Earnings.tsx index 7d6e2ffe..98f397a4 100644 --- a/user-interfaces/provider/src/pages/Earnings.tsx +++ b/user-interfaces/provider/src/pages/Earnings.tsx @@ -10,7 +10,7 @@ import { useProviderInfo, } from '@/state/provider.state' import { RequireProvider } from '@/components/RequireProvider' -import { useBlockNumber } from '@/state/chain.state' +import { useAnchorBlock } from '@/state/chain.state' import { formatTokens, formatBytes, formatBlockNumber } from '@/utils/format' export function Earnings() { @@ -25,12 +25,13 @@ function EarningsContent() { const earnings = useEarnings() const activeAgreements = useActiveAgreements() const providerInfo = useProviderInfo() - const currentBlock = useBlockNumber() + // Agreement start/end blocks are on the pallet's anchor clock. + const anchorBlock = useAnchorBlock() // Calculate agreement progress for each active agreement const agreementProgress = activeAgreements.map((agreement) => { const duration = agreement.endBlock - agreement.startBlock - const elapsed = Math.max(0, Math.min(currentBlock - agreement.startBlock, duration)) + const elapsed = Math.max(0, Math.min(anchorBlock - agreement.startBlock, duration)) const progress = (elapsed / duration) * 100 const earnedSoFar = (agreement.pricePerByte * agreement.maxBytes * BigInt(elapsed)) / BigInt(duration) diff --git a/user-interfaces/provider/src/pages/Overview.tsx b/user-interfaces/provider/src/pages/Overview.tsx index 32886dca..96310624 100644 --- a/user-interfaces/provider/src/pages/Overview.tsx +++ b/user-interfaces/provider/src/pages/Overview.tsx @@ -23,7 +23,7 @@ import { import type { TxStatus } from '@/state/provider.state' import { useSelectedAccount } from '@/state/wallet.state' import { useSelectedNetwork, useSelectedNetworkId } from '@/state/network.state' -import { useChainInfo, useConnectionStatus, useBlockNumber } from '@/state/chain.state' +import { useChainInfo, useConnectionStatus, useAnchorBlock } from '@/state/chain.state' import { RequireProvider } from '@/components/RequireProvider' import { formatBytes, formatTokens, formatDuration, formatHash } from '@/utils/format' @@ -83,7 +83,8 @@ function DangerZone() { const providerInfo = useProviderInfo() const activeAgreements = useActiveAgreements() const selectedAccount = useSelectedAccount() - const currentBlock = useBlockNumber() + // deregister_at is on the pallet's anchor clock. + const anchorBlock = useAnchorBlock() const networkId = useSelectedNetworkId() const [confirming, setConfirming] = useState<'announce' | 'complete' | null>(null) @@ -109,7 +110,7 @@ function DangerZone() { const hasAgreements = activeAgreements.length > 0 const deregisterAt = providerInfo?.deregisterAt const isAnnounced = deregisterAt != null && deregisterAt > 0 - const cooldownElapsed = isAnnounced && currentBlock >= deregisterAt + const cooldownElapsed = isAnnounced && anchorBlock >= deregisterAt const handleProgress = useCallback((status: TxStatus) => { setTxStatus(status) @@ -246,7 +247,7 @@ function DangerZone() { De-registration announced. Completion available after block #{deregisterAt}.

- Current block: #{currentBlock} — {deregisterAt - currentBlock} block{deregisterAt - currentBlock !== 1 ? 's' : ''} remaining + Anchor block: #{anchorBlock} — {deregisterAt - anchorBlock} block{deregisterAt - anchorBlock !== 1 ? 's' : ''} remaining