diff --git a/pallet/src/impls/agreements.rs b/pallet/src/impls/agreements.rs new file mode 100644 index 00000000..1a8215ef --- /dev/null +++ b/pallet/src/impls/agreements.rs @@ -0,0 +1,415 @@ +use crate::*; +use frame_support::{ + pallet_prelude::*, + traits::{Currency, ExistenceRequirement, ReservableCurrency}, +}; +use frame_system::pallet_prelude::*; +use sp_runtime::traits::{CheckedAdd, CheckedMul, SaturatedConversion, Saturating, Zero}; +use storage_primitives::{BucketId, EndAction, ProviderRole, RemovalReason, ReplayError}; + +impl Pallet { + pub fn validate_duration( + settings: &ProviderSettings, + duration: BlockNumberFor, + ) -> DispatchResult { + ensure!( + duration >= settings.min_duration, + Error::::DurationTooShort + ); + ensure!( + duration <= settings.max_duration, + Error::::DurationTooLong + ); + Ok(()) + } + + pub fn calculate_payment( + price_per_byte: BalanceOf, + max_bytes: u64, + duration: BlockNumberFor, + ) -> Result, DispatchError> { + // payment = price_per_byte * max_bytes * duration + // Use saturated_from for type conversions + let bytes_balance: BalanceOf = max_bytes.saturated_into(); + let duration_u128: u128 = duration.saturated_into(); + let duration_balance: BalanceOf = duration_u128.saturated_into(); + + price_per_byte + .checked_mul(&bytes_balance) + .and_then(|p| p.checked_mul(&duration_balance)) + .ok_or(Error::::ArithmeticOverflow.into()) + } + + pub fn finalize_agreement( + bucket_id: BucketId, + provider: &T::AccountId, + agreement: &StorageAgreement, + action: EndAction, + is_early: bool, + ) -> DispatchResult { + let (to_provider, to_burn) = match action { + EndAction::Pay => (agreement.payment_locked, Zero::zero()), + EndAction::Burn { burn_percent } => { + let burn_percent = burn_percent.min(100); + let burn_amount = agreement.payment_locked * burn_percent.into() / 100u32.into(); + let pay_amount = agreement.payment_locked.saturating_sub(burn_amount); + (pay_amount, burn_amount) + } + }; + + // Unreserve from owner + T::Currency::unreserve(&agreement.owner, agreement.payment_locked); + + // Pay provider + if !to_provider.is_zero() { + T::Currency::transfer( + &agreement.owner, + provider, + to_provider, + ExistenceRequirement::KeepAlive, + )?; + } + + // Send burned amount to treasury + if !to_burn.is_zero() { + T::Currency::transfer( + &agreement.owner, + &T::Treasury::get(), + to_burn, + ExistenceRequirement::KeepAlive, + )?; + } + + // Update provider stats + Providers::::mutate(provider, |maybe_provider| { + if let Some(provider_info) = maybe_provider { + provider_info.committed_bytes = provider_info + .committed_bytes + .saturating_sub(agreement.max_bytes); + + if to_burn > Zero::zero() { + provider_info.stats.agreements_burned = + provider_info.stats.agreements_burned.saturating_add(1); + } else { + provider_info.stats.agreements_not_extended = provider_info + .stats + .agreements_not_extended + .saturating_add(1); + } + } + }); + + // Remove from primary_providers if primary + if matches!(agreement.role, ProviderRole::Primary) { + Buckets::::mutate(bucket_id, |maybe_bucket| { + if let Some(bucket) = maybe_bucket { + bucket.primary_providers.retain(|p| p != provider); + } + }); + + let reason = if is_early { + RemovalReason::AdminTerminated + } else { + RemovalReason::Expired + }; + + Self::deposit_event(Event::PrimaryProviderRemoved { + bucket_id, + provider: provider.clone(), + reason, + }); + } + + // Remove agreement + StorageAgreements::::remove(bucket_id, provider); + + Self::deposit_event(Event::AgreementEnded { + bucket_id, + provider: provider.clone(), + payment_to_provider: to_provider, + burned: to_burn, + }); + + Ok(()) + } + + /// Redeem provider-signed terms (used directly by the + /// `establish_storage_agreement` extrinsic and by higher-layer pallets that + /// fold bucket creation into their own flows). + /// + /// Verifies the signature, advances the provider's replay window, + /// then runs the same provider/capacity/stake checks as + /// `create_bucket_with_storage` before creating the bucket + primary + /// agreement. + pub fn establish_storage_agreement_internal( + owner: &T::AccountId, + provider: &T::AccountId, + terms: AgreementTermsOf, + sig: &sp_runtime::MultiSignature, + ) -> Result { + // Origin must match the owner the provider signed for. + ensure!(&terms.owner == owner, Error::::TermsOwnerMismatch); + + // Primary terms must not be bound to an existing bucket — the + // bucket is created at redemption. + ensure!(terms.bucket_id.is_none(), Error::::TermsBucketMismatch); + + // Request's terms.max_bytes must greater than 0 + 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 = frame_system::Pallet::::block_number(); + ensure!(terms.valid_until >= current_block, Error::::TermsExpired); + ensure!( + terms.valid_until <= current_block.saturating_add(T::RequestTimeout::get()), + Error::::TermsValidityTooLong + ); + + // Provider lookup + signature check over + // blake2_256(PRIMARY_TERM_CONTEXT | SCALE(terms)). + let provider_info = Providers::::get(provider).ok_or(Error::::ProviderNotFound)?; + Self::verify_terms_signature( + &provider_info, + &terms, + sig, + storage_primitives::PRIMARY_TERM_CONTEXT, + )?; + + // Replay window: at most once per nonce, within the trailing REPLAY_WINDOW_BITS slots. + ProviderReplayStates::::try_mutate(provider, |window| -> DispatchResult { + window.try_accept(terms.nonce).map_err(|e| match e { + ReplayError::AlreadyUsed => Error::::NonceAlreadyUsed, + ReplayError::TooOld => Error::::NonceTooOld, + })?; + Ok(()) + })?; + + // Validate on-chain provider's state then create bucket + Self::ensure_provider_active(&provider_info)?; + ensure!( + provider_info.settings.accepting_primary, + Error::::ProviderNotAcceptingPrimary + ); + Self::validate_duration(&provider_info.settings, terms.duration)?; + + let new_committed = provider_info + .committed_bytes + .checked_add(terms.max_bytes) + .ok_or(Error::::ArithmeticOverflow)?; + if provider_info.settings.max_capacity > 0 { + ensure!( + new_committed <= provider_info.settings.max_capacity, + Error::::CapacityExceeded + ); + } + + { + let bytes_as_balance: BalanceOf = new_committed.saturated_into(); + let required_stake = T::MinStakePerByte::get() + .checked_mul(&bytes_as_balance) + .ok_or(Error::::ArithmeticOverflow)?; + ensure!( + provider_info.stake >= required_stake, + Error::::InsufficientStakeForBytes + ); + } + + // Pay at the price the provider signed for. + let payment = + Self::calculate_payment(terms.price_per_byte, terms.max_bytes, terms.duration)?; + T::Currency::reserve(owner, payment)?; + + // Bucket creation folded in: owner is sole admin, provider is the + // bucket's single primary. `create_bucket_internal` emits + // `BucketCreated` for us. + let bucket_id = Self::create_bucket_internal(owner, 1, Some(provider))?; + + let expires_at = current_block.saturating_add(terms.duration); + let agreement = StorageAgreement { + owner: owner.clone(), + max_bytes: terms.max_bytes, + payment_locked: payment, + price_per_byte: terms.price_per_byte, + expires_at, + extensions_blocked: false, + role: ProviderRole::Primary, + started_at: current_block, + }; + + Providers::::mutate(provider, |maybe_provider| { + if let Some(p) = maybe_provider { + p.committed_bytes = new_committed; + p.stats.agreements_total = p.stats.agreements_total.saturating_add(1); + p.stats.total_bytes_committed = p + .stats + .total_bytes_committed + .saturating_add(terms.max_bytes); + } + }); + StorageAgreements::::insert(bucket_id, provider, agreement); + + Self::deposit_event(Event::StorageAgreementEstablished { + bucket_id, + provider: provider.clone(), + owner: owner.clone(), + terms, + expires_at, + }); + + Ok(bucket_id) + } + + /// Redeem provider-signed terms for a replica agreement (used directly + /// by the `establish_replica_agreement` extrinsic and by higher-layer + /// pallets that fold replica establishment into their own flows). + /// + /// Verifies the signature, advances the provider's replay window, then + /// runs the provider/capacity/stake checks before opening the replica + /// agreement on an existing bucket. `terms.replica_params` must be + /// `Some(_)`. + pub fn establish_replica_agreement_internal( + owner: &T::AccountId, + bucket_id: BucketId, + provider: &T::AccountId, + terms: AgreementTermsOf, + sig: &sp_runtime::MultiSignature, + ) -> DispatchResult { + // Origin must match the owner the provider signed for. + ensure!(&terms.owner == owner, Error::::TermsOwnerMismatch); + + // The provider's signed quote must be bound to the bucket this + // extrinsic targets. + ensure!( + terms.bucket_id == Some(bucket_id), + Error::::TermsBucketMismatch + ); + + // Request's terms.max_bytes must greater than 0 + 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(); + ensure!(terms.valid_until >= current_block, Error::::TermsExpired); + ensure!( + terms.valid_until <= current_block.saturating_add(T::RequestTimeout::get()), + Error::::TermsValidityTooLong + ); + + // Target bucket must exist. + ensure!( + Buckets::::contains_key(bucket_id), + Error::::BucketNotFound + ); + + // No existing agreement for (bucket, provider). + ensure!( + !StorageAgreements::::contains_key(bucket_id, provider), + Error::::AgreementAlreadyExists + ); + + // Replica terms must be present for a replica agreement. + let replica_terms = terms + .replica_params + .as_ref() + .ok_or(Error::::MissingReplicaTerms)? + .clone(); + + // Provider lookup + signature check over + // blake2_256(REPLICA_TERM_CONTEXT | SCALE(terms)). + let provider_info = Providers::::get(provider).ok_or(Error::::ProviderNotFound)?; + Self::verify_terms_signature( + &provider_info, + &terms, + sig, + storage_primitives::REPLICA_TERM_CONTEXT, + )?; + + // Replay window: at most once per nonce, within the trailing REPLAY_WINDOW_BITS slots. + ProviderReplayStates::::try_mutate(provider, |window| -> DispatchResult { + window.try_accept(terms.nonce).map_err(|e| match e { + ReplayError::AlreadyUsed => Error::::NonceAlreadyUsed, + ReplayError::TooOld => Error::::NonceTooOld, + })?; + Ok(()) + })?; + + // Validate on-chain provider's state. + Self::ensure_provider_active(&provider_info)?; + // Provider is no longer accept replica node + let _ = provider_info + .settings + .replica_sync_price + .ok_or(Error::::ProviderNotAcceptingReplicas)?; + Self::validate_duration(&provider_info.settings, terms.duration)?; + + let new_committed = provider_info + .committed_bytes + .checked_add(terms.max_bytes) + .ok_or(Error::::ArithmeticOverflow)?; + if provider_info.settings.max_capacity > 0 { + ensure!( + new_committed <= provider_info.settings.max_capacity, + Error::::CapacityExceeded + ); + } + + { + let bytes_as_balance: BalanceOf = new_committed.saturated_into(); + let required_stake = T::MinStakePerByte::get() + .checked_mul(&bytes_as_balance) + .ok_or(Error::::ArithmeticOverflow)?; + ensure!( + provider_info.stake >= required_stake, + Error::::InsufficientStakeForBytes + ); + } + + // Pay at the price the provider signed for, plus the sync balance. + let payment = + Self::calculate_payment(terms.price_per_byte, terms.max_bytes, terms.duration)?; + let total_lock = payment + .checked_add(&replica_terms.sync_balance) + .ok_or(Error::::ArithmeticOverflow)?; + T::Currency::reserve(owner, total_lock)?; + + let expires_at = current_block.saturating_add(terms.duration); + let agreement = StorageAgreement { + owner: owner.clone(), + max_bytes: terms.max_bytes, + payment_locked: payment, + price_per_byte: terms.price_per_byte, + expires_at, + extensions_blocked: false, + role: ProviderRole::Replica { + sync_balance: replica_terms.sync_balance, + sync_price: replica_terms.sync_price, + min_sync_interval: replica_terms.min_sync_interval, + last_sync: None, + }, + started_at: current_block, + }; + + Providers::::mutate(provider, |maybe_provider| { + if let Some(p) = maybe_provider { + p.committed_bytes = new_committed; + p.stats.agreements_total = p.stats.agreements_total.saturating_add(1); + p.stats.total_bytes_committed = p + .stats + .total_bytes_committed + .saturating_add(terms.max_bytes); + } + }); + StorageAgreements::::insert(bucket_id, provider, agreement); + + Self::deposit_event(Event::ReplicaAgreementEstablished { + bucket_id, + provider: provider.clone(), + owner: owner.clone(), + terms, + expires_at, + }); + + Ok(()) + } +} diff --git a/pallet/src/impls/buckets.rs b/pallet/src/impls/buckets.rs new file mode 100644 index 00000000..45d676e3 --- /dev/null +++ b/pallet/src/impls/buckets.rs @@ -0,0 +1,182 @@ +use crate::Member; +use crate::*; +use alloc::vec::Vec; +use frame_support::{ + pallet_prelude::*, + traits::{Currency, ExistenceRequirement, ReservableCurrency}, +}; +use sp_core::H256; +use sp_runtime::traits::{SaturatedConversion, Saturating, Zero}; +use storage_primitives::{BucketId, Role}; + +impl Pallet { + /// Internal function to cleanup a bucket and all its agreements. + /// This is called by Layer 1 (drive-registry) when deleting a drive. + /// + /// Returns the total amount refunded to the owner. + pub fn cleanup_bucket_internal( + bucket_id: BucketId, + owner: &T::AccountId, + ) -> Result, DispatchError> { + // Verify bucket exists + let bucket = Buckets::::get(bucket_id).ok_or(Error::::BucketNotFound)?; + + // Verify caller is an admin of the bucket + Self::ensure_admin(owner, &bucket)?; + + let mut total_refunded: BalanceOf = Zero::zero(); + + // End all agreements for this bucket (pay providers fairly) + let agreements: Vec<_> = StorageAgreements::::iter_prefix(bucket_id).collect(); + + for (provider, agreement) in agreements { + // Calculate prorated refund based on remaining time + let current_block = frame_system::Pallet::::block_number(); + let remaining_blocks = agreement.expires_at.saturating_sub(current_block); + + // If there's remaining time, calculate prorated refund + let refund_to_owner = if remaining_blocks > Zero::zero() { + let total_duration = agreement.expires_at.saturating_sub(agreement.started_at); + if total_duration > Zero::zero() { + let remaining_u128: u128 = remaining_blocks.saturated_into(); + let total_u128: u128 = total_duration.saturated_into(); + let payment_u128: u128 = agreement.payment_locked.saturated_into(); + + // refund = payment * (remaining / total) + let refund_u128 = payment_u128 + .saturating_mul(remaining_u128) + .saturating_div(total_u128); + refund_u128.saturated_into() + } else { + Zero::zero() + } + } else { + Zero::zero() + }; + + // Payment to provider = total locked - refund to owner + let payment_to_provider = agreement.payment_locked.saturating_sub(refund_to_owner); + + // Unreserve from owner + T::Currency::unreserve(&agreement.owner, agreement.payment_locked); + + // Pay provider their earned portion + if !payment_to_provider.is_zero() { + T::Currency::transfer( + &agreement.owner, + &provider, + payment_to_provider, + ExistenceRequirement::KeepAlive, + )?; + } + + // Track total refunded (owner keeps the unspent portion) + total_refunded = total_refunded.saturating_add(refund_to_owner); + + // Update provider stats + Providers::::mutate(&provider, |maybe_provider| { + if let Some(provider_info) = maybe_provider { + provider_info.committed_bytes = provider_info + .committed_bytes + .saturating_sub(agreement.max_bytes); + provider_info.stats.agreements_not_extended = provider_info + .stats + .agreements_not_extended + .saturating_add(1); + } + }); + + // Remove agreement + StorageAgreements::::remove(bucket_id, &provider); + + Self::deposit_event(Event::AgreementEnded { + bucket_id, + provider: provider.clone(), + payment_to_provider, + burned: Zero::zero(), + }); + } + + // Clean up reverse index for all members + for member in &bucket.members { + MemberBuckets::::mutate(&member.account, |buckets| { + buckets.retain(|id| *id != bucket_id); + }); + } + + // Remove the bucket itself + Buckets::::remove(bucket_id); + + Self::deposit_event(Event::BucketDeleted { bucket_id }); + + Ok(total_refunded) + } + + /// Create a bucket internally (for use by other pallets like Layer 1 File System). + /// + /// This bypasses the normal extrinsic flow and creates a bucket directly, + /// with the specified account as admin. + /// + /// Parameters: + /// - `admin`: Account that will be the bucket admin. + /// - `min_providers`: Minimum number of primary providers required to + /// sign each checkpoint. + /// - `initial_primary`: Optional provider to seed as the bucket's + /// first `primary_providers` entry. Used by + /// `establish_storage_agreement_internal` to atomically create the + /// bucket together with its primary agreement; pass `None` for + /// buckets that will register primaries later. + /// + /// Returns: bucket_id + pub fn create_bucket_internal( + admin: &T::AccountId, + min_providers: u32, + initial_primary: Option<&T::AccountId>, + ) -> Result { + let bucket_id = NextBucketId::::get(); + NextBucketId::::put(bucket_id.saturating_add(1)); + + let admin_member = Member { + account: admin.clone(), + role: Role::Admin, + }; + + let mut members = BoundedVec::new(); + members + .try_push(admin_member) + .map_err(|_| Error::::MaxMembersReached)?; + + let mut primary_providers = BoundedVec::new(); + if let Some(p) = initial_primary { + primary_providers + .try_push(p.clone()) + .map_err(|_| Error::::MaxPrimaryProvidersReached)?; + } + + let bucket = Bucket { + members, + frozen_start_seq: None, + min_providers, + primary_providers, + snapshot: None, + historical_roots: [(0, H256::zero()); 6], + total_snapshots: 0, + }; + + Buckets::::insert(bucket_id, bucket); + + // Update reverse index for creator + MemberBuckets::::try_mutate(admin, |buckets| { + buckets + .try_push(bucket_id) + .map_err(|_| Error::::TooManyBucketsForMember) + })?; + + Self::deposit_event(Event::BucketCreated { + bucket_id, + admin: admin.clone(), + }); + + Ok(bucket_id) + } +} diff --git a/pallet/src/impls/challenges.rs b/pallet/src/impls/challenges.rs new file mode 100644 index 00000000..bb50ba2c --- /dev/null +++ b/pallet/src/impls/challenges.rs @@ -0,0 +1,120 @@ +use crate::*; +use alloc::vec::Vec; +use frame_support::{ + pallet_prelude::*, + traits::{Currency, ReservableCurrency}, +}; +use frame_system::pallet_prelude::*; +use sp_core::H256; +use sp_runtime::traits::{Saturating, Zero}; +use storage_primitives::{BucketId, ChallengeId}; + +impl Pallet { + pub fn create_challenge( + challenger: T::AccountId, + bucket_id: BucketId, + provider: T::AccountId, + mmr_root: H256, + start_seq: u64, + leaf_index: u64, + chunk_index: u64, + ) -> DispatchResult { + // Calculate deposit (simplified - would be based on expected costs) + let deposit: BalanceOf = 100u32.into(); + + T::Currency::reserve(&challenger, deposit)?; + + let current_block = frame_system::Pallet::::block_number(); + let deadline = current_block.saturating_add(T::ChallengeTimeout::get()); + + let challenge = Challenge { + bucket_id, + provider: provider.clone(), + challenger: challenger.clone(), + mmr_root, + start_seq, + leaf_index, + chunk_index, + deposit, + }; + + let index = Challenges::::mutate(deadline, |challenges| { + let challenges = challenges.get_or_insert_with(Vec::new); + let idx = challenges.len() as u16; + challenges.push(challenge); + idx + }); + + // Update provider stats + Providers::::mutate(&provider, |maybe_provider| { + if let Some(provider_info) = maybe_provider { + provider_info.stats.challenges_received = + provider_info.stats.challenges_received.saturating_add(1); + } + }); + + let challenge_id = ChallengeId { deadline, index }; + + Self::deposit_event(Event::ChallengeCreated { + challenge_id, + bucket_id, + provider, + challenger, + respond_by: deadline, + }); + + Ok(()) + } + + /// Slash a provider who failed to respond to a challenge. + /// + /// This: + /// 1. Slashes the provider's entire stake + /// 2. Refunds the challenger with their deposit plus a reward + /// 3. Updates provider statistics + /// 4. Marks the provider as slashed (so they can be removed from buckets) + pub fn slash_provider_for_failed_challenge( + challenge: &Challenge, + challenge_id: ChallengeId>, + ) { + // Get provider info + if let Some(mut provider_info) = Providers::::get(&challenge.provider) { + // Slash the provider's entire stake + let slashed_amount = provider_info.stake; + + // Unreserve and slash the stake + // In Substrate, slashing typically burns or sends to treasury + let (_, remaining) = T::Currency::slash_reserved(&challenge.provider, slashed_amount); + let actually_slashed = slashed_amount.saturating_sub(remaining); + + // Calculate challenger reward (e.g., 10% of slashed amount, rest goes to treasury) + let challenger_reward = actually_slashed / 10u32.into(); + let to_treasury = actually_slashed.saturating_sub(challenger_reward); + + // Refund challenger's deposit + T::Currency::unreserve(&challenge.challenger, challenge.deposit); + + // Transfer reward to challenger + // Note: We need to handle potential errors gracefully in on_finalize + let _ = T::Currency::deposit_creating(&challenge.challenger, challenger_reward); + + // The rest goes to treasury (burned by slash_reserved) + let _ = to_treasury; // Acknowledged + + // Update provider stats + provider_info.stats.challenges_failed = + provider_info.stats.challenges_failed.saturating_add(1); + provider_info.stake = Zero::zero(); + + Providers::::insert(&challenge.provider, provider_info); + + // Emit event + Self::deposit_event(Event::ChallengeSlashed { + challenge_id, + provider: challenge.provider.clone(), + slashed_amount: actually_slashed, + challenger_reward, + }); + } + } +} diff --git a/pallet/src/impls/checkpoints.rs b/pallet/src/impls/checkpoints.rs new file mode 100644 index 00000000..4b75afb8 --- /dev/null +++ b/pallet/src/impls/checkpoints.rs @@ -0,0 +1,107 @@ +use crate::*; +use frame_support::pallet_prelude::*; +use frame_system::pallet_prelude::*; +use sp_core::H256; +use sp_runtime::traits::{SaturatedConversion, Saturating}; +use storage_primitives::{BucketId, HISTORICAL_ROOT_PRIMES}; + +impl Pallet { + pub fn update_historical_roots( + bucket: &mut Bucket, + current_block: BlockNumberFor, + mmr_root: H256, + ) { + let block_num: u32 = current_block.try_into().unwrap_or(0u32); + + for (i, &prime) in HISTORICAL_ROOT_PRIMES.iter().enumerate() { + let quotient = block_num / prime; + if quotient != bucket.historical_roots[i].0 { + bucket.historical_roots[i] = (quotient, mmr_root); + } + } + } + + pub fn find_matching_root( + bucket: &Bucket, + roots: &[Option; 7], + ) -> Result<(u8, H256), DispatchError> { + // Check current snapshot first + if let (Some(snapshot), Some(root)) = (&bucket.snapshot, roots[0]) { + if snapshot.mmr_root == root { + return Ok((0, root)); + } + } + + // Check historical roots + for i in 0..6 { + if let Some(root) = roots[i + 1] { + if bucket.historical_roots[i].1 == root { + return Ok((i as u8 + 1, root)); + } + } + } + + Err(Error::::InvalidSyncRoot.into()) + } + + /// Calculate the checkpoint window number for a given block. + /// + /// Window 0 starts at block 0, window 1 at block `interval`, etc. + pub fn calculate_window(block: BlockNumberFor, interval: BlockNumberFor) -> u64 { + if interval.is_zero() { + return 0; + } + let block_num: u64 = block.saturated_into(); + let interval_num: u64 = interval.saturated_into(); + block_num / interval_num + } + + /// Calculate the start block for a given checkpoint window. + pub fn window_start_block(window: u64, interval: BlockNumberFor) -> BlockNumberFor { + let interval_num: u64 = interval.saturated_into(); + let start: u64 = window.saturating_mul(interval_num); + start.saturated_into() + } + + /// Calculate the leader index for a given bucket and window. + /// + /// Uses deterministic selection: blake2_256(bucket_id || window) % num_providers. + /// This ensures all providers can independently calculate who the leader is. + pub fn calculate_leader_index(bucket_id: BucketId, window: u64, num_providers: u32) -> u32 { + if num_providers == 0 { + return 0; + } + // Create deterministic seed from bucket_id and window + let mut data = [0u8; 16]; + data[..8].copy_from_slice(&bucket_id.to_le_bytes()); + data[8..].copy_from_slice(&window.to_le_bytes()); + let hash = sp_io::hashing::blake2_256(&data); + // Take first 4 bytes as u32 and mod by num_providers + let seed = u32::from_le_bytes([hash[0], hash[1], hash[2], hash[3]]); + seed % num_providers + } + + /// Get the checkpoint config for a bucket, falling back to defaults. + pub fn get_checkpoint_config( + bucket_id: BucketId, + ) -> storage_primitives::CheckpointWindowConfig> { + CheckpointConfigs::::get(bucket_id).unwrap_or_else(|| { + storage_primitives::CheckpointWindowConfig { + interval: T::DefaultCheckpointInterval::get(), + grace_period: T::DefaultCheckpointGrace::get(), + enabled: true, // Enabled by default + } + }) + } + + /// Check if the current block is within the grace period for a window. + pub fn is_within_grace_period( + current_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 + } +} diff --git a/pallet/src/impls/marketplace.rs b/pallet/src/impls/marketplace.rs new file mode 100644 index 00000000..d87125ea --- /dev/null +++ b/pallet/src/impls/marketplace.rs @@ -0,0 +1,234 @@ +use crate::*; +use alloc::vec::Vec; +use frame_support::pallet_prelude::*; +use sp_runtime::traits::{CheckedMul, SaturatedConversion}; + +impl Pallet { + /// Query available providers that can accept storage of given size + /// + /// This is a helper for Layer 1 to find suitable providers automatically. + /// + /// Parameters: + /// - `max_bytes`: Storage size needed + /// - `accepting_primary`: True to filter for primary providers, false for replica providers + /// + /// Returns: Vec of provider account IDs that can accept the storage + pub fn query_available_providers(max_bytes: u64, accepting_primary: bool) -> Vec { + Providers::::iter() + .filter_map(|(account, info)| { + // Check if provider is accepting the right type of agreements + let accepts_type = if accepting_primary { + info.settings.accepting_primary + } else { + info.settings.replica_sync_price.is_some() + }; + + if !accepts_type { + return None; + } + + // Check if provider has capacity + if Self::query_can_accept_bytes(&account, max_bytes) { + Some(account) + } else { + None + } + }) + .collect() + } + + /// Find providers matching the given storage requirements. + pub fn query_find_matching_providers( + requirements: crate::runtime_api::StorageRequirements, + limit: u32, + ) -> Vec { + use crate::runtime_api::{MatchedProvider, PartialMatchReason}; + + let mut results: Vec = Vec::new(); + + for (account, info) in Providers::::iter() { + // Skip providers that have announced deregistration — they are + // winding down and must not be offered for new agreements. + if info.deregister_at.is_some() { + continue; + } + + let max_capacity = info.settings.max_capacity; + let available = if max_capacity > 0 { + max_capacity.saturating_sub(info.committed_bytes) + } else { + u64::MAX // Unlimited + }; + + let price: u128 = info.settings.price_per_byte.saturated_into(); + let min_dur: u32 = info.settings.min_duration.saturated_into(); + let max_dur: u32 = info.settings.max_duration.saturated_into(); + + // Determine match score and partial reason + let mut score: u8 = 100; + let mut partial_reason: Option = None; + + // Check accepting status + // Primary required: must accept primary + // Replica acceptable: must accept primary OR have replica sync price + let not_accepting = if requirements.primary_only { + !info.settings.accepting_primary + } else { + !info.settings.accepting_primary && info.settings.replica_sync_price.is_none() + }; + if not_accepting { + score = 0; + partial_reason = Some(PartialMatchReason::NotAccepting); + } + + // Check capacity + if score > 0 && available < requirements.bytes_needed { + score = score.saturating_sub(50); + if partial_reason.is_none() { + partial_reason = Some(PartialMatchReason::InsufficientCapacity); + } + } + + // Check price + if score > 0 && price > requirements.max_price_per_byte { + score = score.saturating_sub(30); + if partial_reason.is_none() { + partial_reason = Some(PartialMatchReason::PriceTooHigh); + } + } + + // Check duration + if score > 0 + && (requirements.min_duration < min_dur || requirements.min_duration > max_dur) + { + score = score.saturating_sub(20); + if partial_reason.is_none() { + partial_reason = Some(PartialMatchReason::DurationMismatch); + } + } + + // Build the available_capacity field + let available_capacity = if max_capacity > 0 { + Some(available) + } else { + None + }; + + let provider_response = crate::runtime_api::ProviderInfoResponse { + multiaddr: info.multiaddr.to_vec(), + public_key: info.public_key.to_vec(), + stake: info.stake.saturated_into::(), + committed_bytes: info.committed_bytes, + min_duration: min_dur, + max_duration: max_dur, + price_per_byte: price, + accepting_primary: info.settings.accepting_primary, + replica_sync_price: info + .settings + .replica_sync_price + .map(|p| p.saturated_into::()), + accepting_extensions: info.settings.accepting_extensions, + registered_at: info.stats.registered_at.saturated_into::(), + agreements_total: info.stats.agreements_total, + agreements_extended: info.stats.agreements_extended, + agreements_not_extended: info.stats.agreements_not_extended, + agreements_burned: info.stats.agreements_burned, + challenges_received: info.stats.challenges_received, + challenges_failed: info.stats.challenges_failed, + max_capacity, + available_capacity, + }; + + results.push(MatchedProvider { + account: account.encode(), + info: provider_response, + match_score: score, + available_capacity, + partial_reason, + }); + } + + // Sort by score descending, then by price ascending for ties + results.sort_by(|a, b| { + b.match_score + .cmp(&a.match_score) + .then(a.info.price_per_byte.cmp(&b.info.price_per_byte)) + }); + + results.truncate(limit as usize); + results + } + + /// Get providers with sufficient capacity for the given bytes (paginated). + pub fn query_providers_with_capacity( + bytes_needed: u64, + offset: u32, + limit: u32, + ) -> Vec<(T::AccountId, crate::runtime_api::ProviderInfoResponse)> { + Providers::::iter() + .filter(|(_, info)| { + // Check accepting status + if !info.settings.accepting_primary && info.settings.replica_sync_price.is_none() { + return false; + } + + // Check capacity + let max_capacity = info.settings.max_capacity; + if max_capacity > 0 { + let available = max_capacity.saturating_sub(info.committed_bytes); + if available < bytes_needed { + return false; + } + } + + // Check stake (can they back the additional bytes?) + let new_committed = info.committed_bytes.saturating_add(bytes_needed); + let bytes_as_balance: BalanceOf = new_committed.saturated_into(); + if let Some(required_stake) = + T::MinStakePerByte::get().checked_mul(&bytes_as_balance) + { + return info.stake >= required_stake; + } + false + }) + .skip(offset as usize) + .take(limit as usize) + .map(|(account, info)| { + let max_capacity = info.settings.max_capacity; + let available_capacity = if max_capacity > 0 { + Some(max_capacity.saturating_sub(info.committed_bytes)) + } else { + None + }; + + ( + account, + crate::runtime_api::ProviderInfoResponse { + multiaddr: info.multiaddr.to_vec(), + public_key: info.public_key.to_vec(), + stake: info.stake.saturated_into::(), + committed_bytes: info.committed_bytes, + min_duration: info.settings.min_duration.saturated_into::(), + max_duration: info.settings.max_duration.saturated_into::(), + price_per_byte: info.settings.price_per_byte.saturated_into::(), + accepting_primary: info.settings.accepting_primary, + replica_sync_price: info + .settings + .replica_sync_price + .map(|p| p.saturated_into::()), + accepting_extensions: info.settings.accepting_extensions, + registered_at: info.stats.registered_at.saturated_into::(), + agreements_total: info.stats.agreements_total, + agreements_extended: info.stats.agreements_extended, + agreements_not_extended: info.stats.agreements_not_extended, + agreements_burned: info.stats.agreements_burned, + challenges_received: info.stats.challenges_received, + challenges_failed: info.stats.challenges_failed, + max_capacity, + available_capacity, + }, + ) + }) + .collect() + } +} diff --git a/pallet/src/impls/members.rs b/pallet/src/impls/members.rs new file mode 100644 index 00000000..b92838ef --- /dev/null +++ b/pallet/src/impls/members.rs @@ -0,0 +1,137 @@ +use crate::Member; +use crate::*; +use frame_support::pallet_prelude::*; +use storage_primitives::{BucketId, Role}; + +impl Pallet { + pub fn ensure_admin(who: &T::AccountId, bucket: &Bucket) -> DispatchResult { + ensure!( + bucket + .members + .iter() + .any(|m| &m.account == who && m.role == Role::Admin), + Error::::NotBucketAdmin + ); + Ok(()) + } + + /// Single `bucket.member` iteration to find `member` matching. Returns: + /// - the target member's index (if present), + /// - whether that member currently holds `Role::Admin`, + /// - the total number of admins in the bucket. + pub fn locate_member(bucket: &Bucket, member: &T::AccountId) -> (Option, bool, u32) { + let mut target_idx = None; + let mut target_is_admin = false; + let mut admin_count: u32 = 0; + for (i, m) in bucket.members.iter().enumerate() { + if m.role == Role::Admin { + admin_count = admin_count.saturating_add(1); + } + if &m.account == member { + target_idx = Some(i); + target_is_admin = m.role == Role::Admin; + } + } + (target_idx, target_is_admin, admin_count) + } + + pub fn ensure_writer_or_admin(who: &T::AccountId, bucket: &Bucket) -> DispatchResult { + ensure!( + bucket + .members + .iter() + .any(|m| &m.account == who && (m.role == Role::Admin || m.role == Role::Writer)), + Error::::NotBucketWriter + ); + Ok(()) + } + + /// Add or update a member's role on a bucket (callable from other pallets). + /// + /// The `caller` must be an Admin of the bucket. + pub fn set_member_internal( + caller: &T::AccountId, + bucket_id: BucketId, + member: T::AccountId, + role: Role, + ) -> DispatchResult { + Buckets::::try_mutate(bucket_id, |maybe_bucket| -> DispatchResult { + let bucket = maybe_bucket.as_mut().ok_or(Error::::BucketNotFound)?; + + Self::ensure_admin(caller, bucket)?; + + let (target_idx, target_is_admin, admin_count) = Self::locate_member(bucket, &member); + if let Some(idx) = target_idx { + if target_is_admin && role != Role::Admin { + // Admins can only demote themselves, never another admin. + ensure!(member == *caller, Error::::CannotDemoteAdmin); + // And even self-demotion must leave at least one admin. + ensure!(admin_count > 1, Error::::LastAdminCannotBeRemoved); + } + bucket.members[idx].role = role; + } else { + let new_member = Member { + account: member.clone(), + role, + }; + bucket + .members + .try_push(new_member) + .map_err(|_| Error::::MaxMembersReached)?; + + MemberBuckets::::try_mutate(&member, |buckets| { + if !buckets.contains(&bucket_id) { + buckets + .try_push(bucket_id) + .map_err(|_| Error::::TooManyBucketsForMember) + } else { + Ok(()) + } + })?; + } + + Self::deposit_event(Event::MemberSet { + bucket_id, + member, + role, + }); + + Ok(()) + }) + } + + /// Remove a member from a bucket (callable from other pallets). + /// + /// The `caller` must be an Admin of the bucket. + pub fn remove_member_internal( + caller: &T::AccountId, + bucket_id: BucketId, + member: T::AccountId, + ) -> DispatchResult { + Buckets::::try_mutate(bucket_id, |maybe_bucket| -> DispatchResult { + let bucket = maybe_bucket.as_mut().ok_or(Error::::BucketNotFound)?; + + Self::ensure_admin(caller, bucket)?; + + let (target_idx, target_is_admin, admin_count) = Self::locate_member(bucket, &member); + let member_idx = target_idx.ok_or(Error::::MemberNotFound)?; + + if target_is_admin { + // Admins can only remove themselves, never another admin. + ensure!(member == *caller, Error::::CannotDemoteAdmin); + // And even self-removal must leave at least one admin. + ensure!(admin_count > 1, Error::::LastAdminCannotBeRemoved); + } + + bucket.members.remove(member_idx); + + MemberBuckets::::mutate(&member, |buckets| { + buckets.retain(|id| *id != bucket_id); + }); + + Self::deposit_event(Event::MemberRemoved { bucket_id, member }); + + Ok(()) + }) + } +} diff --git a/pallet/src/impls/mod.rs b/pallet/src/impls/mod.rs new file mode 100644 index 00000000..5acac7a9 --- /dev/null +++ b/pallet/src/impls/mod.rs @@ -0,0 +1,9 @@ +pub mod agreements; +pub mod buckets; +pub mod challenges; +pub mod checkpoints; +pub mod marketplace; +pub mod members; +pub mod providers; +pub mod queries; +pub mod signatures; diff --git a/pallet/src/impls/providers.rs b/pallet/src/impls/providers.rs new file mode 100644 index 00000000..fb2f094f --- /dev/null +++ b/pallet/src/impls/providers.rs @@ -0,0 +1,112 @@ +use crate::*; +use frame_support::{pallet_prelude::*, traits::ReservableCurrency}; +use sp_runtime::traits::CheckedMul; +use storage_primitives::ReplayWindow; + +impl Pallet { + /// Reject any path that would create a new commitment for a + /// provider who has announced deregistration. `deregister_provider` + /// also flips `accepting_primary`/`accepting_extensions` to `false`, + pub fn ensure_provider_active(provider: &ProviderInfo) -> DispatchResult { + ensure!( + provider.deregister_at.is_none(), + Error::::DeregisterAnnounced + ); + Ok(()) + } + + /// Validate provider settings against committed bytes and stake. + /// + /// Shared by `update_provider_settings` and `register_provider_internal`. + pub fn validate_settings( + settings: &ProviderSettings, + committed_bytes: u64, + stake: BalanceOf, + ) -> DispatchResult { + ensure!( + settings.min_duration <= settings.max_duration, + Error::::MinDurationExceedsMaxDuration + ); + + // Validate max_capacity >= committed_bytes (unless 0 = unlimited) + if settings.max_capacity > 0 { + ensure!( + settings.max_capacity >= committed_bytes, + Error::::CapacityBelowCommitted + ); + + // Validate stake backs declared capacity + use sp_runtime::traits::SaturatedConversion; + let capacity_as_balance: BalanceOf = settings.max_capacity.saturated_into(); + let required_stake = T::MinStakePerByte::get() + .checked_mul(&capacity_as_balance) + .ok_or(Error::::ArithmeticOverflow)?; + ensure!( + stake >= required_stake, + Error::::InsufficientStakeForCapacity + ); + } + + Ok(()) + } + + /// Register a provider, reserving their stake. + /// + /// Shared by the `register_provider` extrinsic (which passes + /// `ProviderSettings::default()`) and genesis build (which passes the + /// full settings, since post-genesis there is no one to call + /// `update_provider_settings`). + pub fn register_provider_internal( + who: &T::AccountId, + multiaddr: BoundedVec, + public_key: BoundedVec>, + stake: BalanceOf, + settings: ProviderSettings, + ) -> DispatchResult { + ensure!( + !Providers::::contains_key(who), + Error::::ProviderAlreadyRegistered + ); + ensure!( + stake >= T::MinProviderStake::get(), + Error::::InsufficientStake + ); + + // Validate public key length (32 bytes for Sr25519/Ed25519, 33 for Ecdsa compressed) + let key_len = public_key.len(); + ensure!( + key_len == 32 || key_len == 33 || key_len == 64, + Error::::InvalidPublicKey + ); + + Self::validate_settings(&settings, 0, stake)?; + + // Reserve stake + T::Currency::reserve(who, stake)?; + + let current_block = frame_system::Pallet::::block_number(); + + let provider_info = ProviderInfo { + multiaddr, + public_key, + stake, + committed_bytes: 0, + settings, + stats: ProviderStats { + registered_at: current_block, + ..Default::default() + }, + deregister_at: None, + }; + + Providers::::insert(who, provider_info); + ProviderReplayStates::::insert(who, ReplayWindow::default()); + + Self::deposit_event(Event::ProviderRegistered { + provider: who.clone(), + stake, + }); + + Ok(()) + } +} diff --git a/pallet/src/impls/queries.rs b/pallet/src/impls/queries.rs new file mode 100644 index 00000000..e4f4dcfd --- /dev/null +++ b/pallet/src/impls/queries.rs @@ -0,0 +1,349 @@ +use crate::*; +use alloc::vec::Vec; +use frame_support::pallet_prelude::*; +use frame_system::pallet_prelude::BlockNumberFor; +use sp_runtime::traits::{CheckedMul, SaturatedConversion}; +use storage_primitives::{BucketId, BucketSnapshot, ProviderRole}; + +fn challenge_to_response( + deadline: BlockNumberFor, + idx: usize, + c: Challenge, +) -> crate::runtime_api::ChallengeResponse { + crate::runtime_api::ChallengeResponse { + bucket_id: c.bucket_id, + provider: c.provider.encode(), + challenger: c.challenger.encode(), + mmr_root: c.mmr_root, + start_seq: c.start_seq, + leaf_index: c.leaf_index, + chunk_index: c.chunk_index, + deadline: deadline.saturated_into(), + index: idx as u16, + deposit: c.deposit.saturated_into::(), + } +} + +impl Pallet { + /// Query provider information. + pub fn query_provider_info( + provider: &T::AccountId, + ) -> Option { + Providers::::get(provider).map(|info| { + let max_capacity = info.settings.max_capacity; + let available_capacity = if max_capacity > 0 { + Some(max_capacity.saturating_sub(info.committed_bytes)) + } else { + None // Unlimited + }; + + crate::runtime_api::ProviderInfoResponse { + multiaddr: info.multiaddr.to_vec(), + public_key: info.public_key.to_vec(), + stake: info.stake.saturated_into::(), + committed_bytes: info.committed_bytes, + min_duration: info.settings.min_duration.saturated_into::(), + max_duration: info.settings.max_duration.saturated_into::(), + price_per_byte: info.settings.price_per_byte.saturated_into::(), + accepting_primary: info.settings.accepting_primary, + replica_sync_price: info + .settings + .replica_sync_price + .map(|p| p.saturated_into::()), + accepting_extensions: info.settings.accepting_extensions, + registered_at: info.stats.registered_at.saturated_into::(), + agreements_total: info.stats.agreements_total, + agreements_extended: info.stats.agreements_extended, + agreements_not_extended: info.stats.agreements_not_extended, + agreements_burned: info.stats.agreements_burned, + challenges_received: info.stats.challenges_received, + challenges_failed: info.stats.challenges_failed, + max_capacity, + available_capacity, + } + }) + } + + /// Query all providers (paginated). + pub fn query_providers( + offset: u32, + limit: u32, + ) -> Vec<(T::AccountId, crate::runtime_api::ProviderInfoResponse)> { + Providers::::iter() + .skip(offset as usize) + .take(limit as usize) + .map(|(account, info)| { + let max_capacity = info.settings.max_capacity; + let available_capacity = if max_capacity > 0 { + Some(max_capacity.saturating_sub(info.committed_bytes)) + } else { + None // Unlimited + }; + + ( + account, + crate::runtime_api::ProviderInfoResponse { + multiaddr: info.multiaddr.to_vec(), + public_key: info.public_key.to_vec(), + stake: info.stake.saturated_into::(), + committed_bytes: info.committed_bytes, + min_duration: info.settings.min_duration.saturated_into::(), + max_duration: info.settings.max_duration.saturated_into::(), + price_per_byte: info.settings.price_per_byte.saturated_into::(), + accepting_primary: info.settings.accepting_primary, + replica_sync_price: info + .settings + .replica_sync_price + .map(|p| p.saturated_into::()), + accepting_extensions: info.settings.accepting_extensions, + registered_at: info.stats.registered_at.saturated_into::(), + agreements_total: info.stats.agreements_total, + agreements_extended: info.stats.agreements_extended, + agreements_not_extended: info.stats.agreements_not_extended, + agreements_burned: info.stats.agreements_burned, + challenges_received: info.stats.challenges_received, + challenges_failed: info.stats.challenges_failed, + max_capacity, + available_capacity, + }, + ) + }) + .collect() + } + + /// Query bucket information. + pub fn query_bucket_info(bucket_id: BucketId) -> Option { + Buckets::::get(bucket_id).map(|bucket| crate::runtime_api::BucketResponse { + bucket_id, + members: bucket + .members + .iter() + .map(|m| crate::runtime_api::BucketMemberResponse { + account: m.account.encode(), + role: m.role, + }) + .collect(), + frozen_start_seq: bucket.frozen_start_seq, + min_providers: bucket.min_providers, + primary_providers: bucket + .primary_providers + .iter() + .map(|p| p.encode()) + .collect(), + snapshot: bucket.snapshot.map(|s| BucketSnapshot { + mmr_root: s.mmr_root, + start_seq: s.start_seq, + leaf_count: s.leaf_count, + checkpoint_block: s.checkpoint_block.saturated_into::(), + primary_signers: s.primary_signers.clone(), + }), + total_snapshots: bucket.total_snapshots, + }) + } + + /// Query bucket providers. + pub fn query_bucket_providers(bucket_id: BucketId) -> Vec { + Buckets::::get(bucket_id) + .map(|bucket| bucket.primary_providers.to_vec()) + .unwrap_or_default() + } + + /// Query agreement information. + pub fn query_agreement_info( + bucket_id: BucketId, + provider: &T::AccountId, + ) -> Option { + StorageAgreements::::get(bucket_id, provider).map(|agreement| { + crate::runtime_api::AgreementResponse { + bucket_id, + owner: agreement.owner.encode(), + provider: provider.encode(), + max_bytes: agreement.max_bytes, + payment_locked: agreement.payment_locked.saturated_into::(), + price_per_byte: agreement.price_per_byte.saturated_into::(), + expires_at: agreement.expires_at.saturated_into::(), + extensions_blocked: agreement.extensions_blocked, + role: match agreement.role { + ProviderRole::Primary => ProviderRole::Primary, + ProviderRole::Replica { + sync_balance, + sync_price, + min_sync_interval, + last_sync, + } => ProviderRole::Replica { + sync_balance: sync_balance.saturated_into::(), + sync_price: sync_price.saturated_into::(), + min_sync_interval: min_sync_interval.saturated_into::(), + last_sync: last_sync + .map(|(root, block)| (root, block.saturated_into::())), + }, + }, + started_at: agreement.started_at.saturated_into::(), + } + }) + } + + /// Query all agreements for a bucket. + pub fn query_bucket_agreements( + bucket_id: BucketId, + ) -> Vec { + StorageAgreements::::iter_prefix(bucket_id) + .map( + |(provider, agreement)| crate::runtime_api::AgreementResponse { + bucket_id, + owner: agreement.owner.encode(), + provider: provider.encode(), + max_bytes: agreement.max_bytes, + payment_locked: agreement.payment_locked.saturated_into::(), + price_per_byte: agreement.price_per_byte.saturated_into::(), + expires_at: agreement.expires_at.saturated_into::(), + extensions_blocked: agreement.extensions_blocked, + role: match agreement.role { + ProviderRole::Primary => ProviderRole::Primary, + ProviderRole::Replica { + sync_balance, + sync_price, + min_sync_interval, + last_sync, + } => ProviderRole::Replica { + sync_balance: sync_balance.saturated_into::(), + sync_price: sync_price.saturated_into::(), + min_sync_interval: min_sync_interval.saturated_into::(), + last_sync: last_sync + .map(|(root, block)| (root, block.saturated_into::())), + }, + }, + started_at: agreement.started_at.saturated_into::(), + }, + ) + .collect() + } + + /// Query all bucket IDs (paginated). + pub fn query_bucket_ids(offset: u32, limit: u32) -> Vec { + Buckets::::iter_keys() + .skip(offset as usize) + .take(limit as usize) + .collect() + } + + /// Query all agreements for a provider. + pub fn query_provider_agreements( + provider: &T::AccountId, + ) -> Vec { + StorageAgreements::::iter() + .filter(|(_, p, _)| p == provider) + .map( + |(bucket_id, _, agreement)| crate::runtime_api::AgreementResponse { + bucket_id, + owner: agreement.owner.encode(), + provider: provider.encode(), + max_bytes: agreement.max_bytes, + payment_locked: agreement.payment_locked.saturated_into::(), + price_per_byte: agreement.price_per_byte.saturated_into::(), + expires_at: agreement.expires_at.saturated_into::(), + extensions_blocked: agreement.extensions_blocked, + role: match agreement.role { + ProviderRole::Primary => ProviderRole::Primary, + ProviderRole::Replica { + sync_balance, + sync_price, + min_sync_interval, + last_sync, + } => ProviderRole::Replica { + sync_balance: sync_balance.saturated_into::(), + sync_price: sync_price.saturated_into::(), + min_sync_interval: min_sync_interval.saturated_into::(), + last_sync: last_sync + .map(|(root, block)| (root, block.saturated_into::())), + }, + }, + started_at: agreement.started_at.saturated_into::(), + }, + ) + .collect() + } + + /// Query challenges expiring at a specific block. + pub fn query_challenges_at( + block: BlockNumberFor, + ) -> Vec { + Challenges::::get(block) + .unwrap_or_default() + .into_iter() + .enumerate() + .map(|(idx, c)| challenge_to_response::(block, idx, c)) + .collect() + } + + /// Query all challenges for a specific bucket. + pub fn query_bucket_challenges( + bucket_id: BucketId, + ) -> Vec { + Challenges::::iter() + .flat_map(|(block, challenges)| { + challenges + .into_iter() + .enumerate() + .map(move |(idx, c)| (block, idx, c)) + }) + .filter(|(_, _, c)| c.bucket_id == bucket_id) + .map(|(block, idx, c)| challenge_to_response::(block, idx, c)) + .collect() + } + + /// Query all challenges targeting a specific provider. + pub fn query_provider_challenges( + provider: &T::AccountId, + ) -> Vec { + Challenges::::iter() + .flat_map(|(block, challenges)| { + challenges + .into_iter() + .enumerate() + .map(move |(idx, c)| (block, idx, c)) + }) + .filter(|(_, _, c)| &c.provider == provider) + .map(|(block, idx, c)| challenge_to_response::(block, idx, c)) + .collect() + } + + /// Query all challenges created by a specific challenger. + pub fn query_challenger_challenges( + challenger: &T::AccountId, + ) -> Vec { + Challenges::::iter() + .flat_map(|(block, challenges)| { + challenges + .into_iter() + .enumerate() + .map(move |(idx, c)| (block, idx, c)) + }) + .filter(|(_, _, c)| &c.challenger == challenger) + .map(|(block, idx, c)| challenge_to_response::(block, idx, c)) + .collect() + } + + /// Check if provider can accept additional bytes. + pub fn query_can_accept_bytes(provider: &T::AccountId, additional_bytes: u64) -> bool { + if let Some(provider_info) = Providers::::get(provider) { + let new_committed_bytes = provider_info + .committed_bytes + .saturating_add(additional_bytes); + + // Check capacity constraint + if provider_info.settings.max_capacity > 0 + && new_committed_bytes > provider_info.settings.max_capacity + { + return false; + } + + let bytes_as_balance: BalanceOf = new_committed_bytes.saturated_into(); + + if let Some(required_stake) = T::MinStakePerByte::get().checked_mul(&bytes_as_balance) { + return provider_info.stake >= required_stake; + } + } + false + } +} diff --git a/pallet/src/impls/signatures.rs b/pallet/src/impls/signatures.rs new file mode 100644 index 00000000..3a0fd9b8 --- /dev/null +++ b/pallet/src/impls/signatures.rs @@ -0,0 +1,93 @@ +use crate::*; +use frame_support::pallet_prelude::*; + +impl Pallet { + /// Verify a MultiSignature against an encoded message using stored public key. + /// + /// This: + /// 1. Retrieves the provider's registered public key from storage + /// 2. Reconstructs the appropriate public key type from raw bytes + /// 3. Verifies the signature matches the message and public key + /// + /// Returns Error::InvalidSignature if verification fails. + pub fn verify_signature( + signature: &sp_runtime::MultiSignature, + message: &[u8], + signer: &T::AccountId, + ) -> DispatchResult { + use sp_runtime::traits::Verify; + + // Get the provider's registered public key + let provider = Providers::::get(signer).ok_or(Error::::ProviderNotFound)?; + let public_key_bytes = provider.public_key.as_slice(); + + // Convert public key to AccountId32 based on signature type + let account_id = match signature { + sp_runtime::MultiSignature::Sr25519(_) | sp_runtime::MultiSignature::Ed25519(_) => { + // Sr25519 and Ed25519 public keys are 32 bytes, directly used as AccountId32 + if public_key_bytes.len() != 32 { + return Err(Error::::InvalidPublicKey.into()); + } + let mut key_bytes = [0u8; 32]; + key_bytes.copy_from_slice(public_key_bytes); + sp_runtime::AccountId32::new(key_bytes) + } + sp_runtime::MultiSignature::Ecdsa(_) | sp_runtime::MultiSignature::Eth(_) => { + // Ecdsa/Eth public keys are 33 bytes (compressed), AccountId32 is blake2_256 hash + if public_key_bytes.len() != 33 { + return Err(Error::::InvalidPublicKey.into()); + } + let hash = sp_io::hashing::blake2_256(public_key_bytes); + sp_runtime::AccountId32::new(hash) + } + }; + + // Verify signature against the account ID + let is_valid = signature.verify(message, &account_id); + + ensure!(is_valid, Error::::InvalidSignature); + + Ok(()) + } + + /// Verify a provider signature over a SCALE-encoded + /// [`AgreementTermsOf`]. The signed payload is + /// `blake2_256(context | terms.encode())`, where `context` is the + /// domain-separation prefix for the redemption path + /// ([`storage_primitives::PRIMARY_TERM_CONTEXT`] or + /// [`storage_primitives::REPLICA_TERM_CONTEXT`]) — the caller, not + /// the terms, decides it, so a quote signed for one flavour can + /// never be redeemed as the other. + pub fn verify_terms_signature( + provider_info: &ProviderInfo, + terms: &AgreementTermsOf, + sig: &sp_runtime::MultiSignature, + context: &[u8], + ) -> DispatchResult { + use sp_runtime::traits::Verify; + + let public_key_bytes = provider_info.public_key.as_slice(); + let account_id = match sig { + sp_runtime::MultiSignature::Sr25519(_) | sp_runtime::MultiSignature::Ed25519(_) => { + ensure!(public_key_bytes.len() == 32, Error::::InvalidPublicKey); + let mut key_bytes = [0u8; 32]; + key_bytes.copy_from_slice(public_key_bytes); + sp_runtime::AccountId32::new(key_bytes) + } + sp_runtime::MultiSignature::Ecdsa(_) | sp_runtime::MultiSignature::Eth(_) => { + ensure!(public_key_bytes.len() == 33, Error::::InvalidPublicKey); + let hash = sp_io::hashing::blake2_256(public_key_bytes); + sp_runtime::AccountId32::new(hash) + } + }; + + let mut payload = context.to_vec(); + terms.encode_to(&mut payload); + let hash = sp_io::hashing::blake2_256(&payload); + ensure!( + sig.verify(&hash[..], &account_id), + Error::::InvalidProviderSignature + ); + Ok(()) + } +} diff --git a/pallet/src/lib.rs b/pallet/src/lib.rs index 56f0cdf7..33f8ec33 100644 --- a/pallet/src/lib.rs +++ b/pallet/src/lib.rs @@ -21,6 +21,7 @@ extern crate alloc; pub use pallet::*; +pub mod impls; pub mod runtime_api; pub mod weights; pub use weights::WeightInfo; @@ -46,10 +47,10 @@ pub mod pallet { }; use frame_system::pallet_prelude::*; use sp_core::H256; - use sp_runtime::traits::{Bounded, CheckedAdd, SaturatedConversion, Saturating, Verify, Zero}; + use sp_runtime::traits::{Bounded, CheckedAdd, Saturating, Zero}; use storage_primitives::{ BucketId, BucketSnapshot, ChallengeId, CommitmentPayload, EndAction, MerkleProof, MmrProof, - ProviderRole, RemovalReason, ReplayError, ReplayWindow, Role, HISTORICAL_ROOT_PRIMES, + ProviderRole, RemovalReason, ReplayWindow, Role, }; pub type BalanceOf = @@ -2768,1747 +2769,4 @@ pub mod pallet { ) } } - - // ───────────────────────────────────────────────────────────────────────── - // Helper Functions - // ───────────────────────────────────────────────────────────────────────── - - impl Pallet { - /// Verify a MultiSignature against an encoded message using stored public key. - /// - /// This: - /// 1. Retrieves the provider's registered public key from storage - /// 2. Reconstructs the appropriate public key type from raw bytes - /// 3. Verifies the signature matches the message and public key - /// - /// Returns Error::InvalidSignature if verification fails. - fn verify_signature( - signature: &sp_runtime::MultiSignature, - message: &[u8], - signer: &T::AccountId, - ) -> DispatchResult { - use sp_runtime::traits::Verify; - - // Get the provider's registered public key - let provider = Providers::::get(signer).ok_or(Error::::ProviderNotFound)?; - let public_key_bytes = provider.public_key.as_slice(); - - // Convert public key to AccountId32 based on signature type - let account_id = match signature { - sp_runtime::MultiSignature::Sr25519(_) | sp_runtime::MultiSignature::Ed25519(_) => { - // Sr25519 and Ed25519 public keys are 32 bytes, directly used as AccountId32 - if public_key_bytes.len() != 32 { - return Err(Error::::InvalidPublicKey.into()); - } - let mut key_bytes = [0u8; 32]; - key_bytes.copy_from_slice(public_key_bytes); - sp_runtime::AccountId32::new(key_bytes) - } - sp_runtime::MultiSignature::Ecdsa(_) | sp_runtime::MultiSignature::Eth(_) => { - // Ecdsa/Eth public keys are 33 bytes (compressed), AccountId32 is blake2_256 hash - if public_key_bytes.len() != 33 { - return Err(Error::::InvalidPublicKey.into()); - } - let hash = sp_io::hashing::blake2_256(public_key_bytes); - sp_runtime::AccountId32::new(hash) - } - }; - - // Verify signature against the account ID - let is_valid = signature.verify(message, &account_id); - - ensure!(is_valid, Error::::InvalidSignature); - - Ok(()) - } - - /// Verify a provider signature over a SCALE-encoded - /// [`AgreementTermsOf`]. The signed payload is - /// `blake2_256(context | terms.encode())`, where `context` is the - /// domain-separation prefix for the redemption path - /// ([`storage_primitives::PRIMARY_TERM_CONTEXT`] or - /// [`storage_primitives::REPLICA_TERM_CONTEXT`]) — the caller, not - /// the terms, decides it, so a quote signed for one flavour can - /// never be redeemed as the other. - fn verify_terms_signature( - provider_info: &ProviderInfo, - terms: &AgreementTermsOf, - sig: &sp_runtime::MultiSignature, - context: &[u8], - ) -> DispatchResult { - let public_key_bytes = provider_info.public_key.as_slice(); - let account_id = match sig { - sp_runtime::MultiSignature::Sr25519(_) | sp_runtime::MultiSignature::Ed25519(_) => { - ensure!(public_key_bytes.len() == 32, Error::::InvalidPublicKey); - let mut key_bytes = [0u8; 32]; - key_bytes.copy_from_slice(public_key_bytes); - sp_runtime::AccountId32::new(key_bytes) - } - sp_runtime::MultiSignature::Ecdsa(_) | sp_runtime::MultiSignature::Eth(_) => { - ensure!(public_key_bytes.len() == 33, Error::::InvalidPublicKey); - let hash = sp_io::hashing::blake2_256(public_key_bytes); - sp_runtime::AccountId32::new(hash) - } - }; - - let mut payload = context.to_vec(); - terms.encode_to(&mut payload); - let hash = sp_io::hashing::blake2_256(&payload); - ensure!( - sig.verify(&hash[..], &account_id), - Error::::InvalidProviderSignature - ); - Ok(()) - } - - fn ensure_admin(who: &T::AccountId, bucket: &Bucket) -> DispatchResult { - ensure!( - bucket - .members - .iter() - .any(|m| &m.account == who && m.role == Role::Admin), - Error::::NotBucketAdmin - ); - Ok(()) - } - - /// Single `bucket.member` iteration to find `member` matching. Returns: - /// - the target member's index (if present), - /// - whether that member currently holds `Role::Admin`, - /// - the total number of admins in the bucket. - fn locate_member(bucket: &Bucket, member: &T::AccountId) -> (Option, bool, u32) { - let mut target_idx = None; - let mut target_is_admin = false; - let mut admin_count: u32 = 0; - for (i, m) in bucket.members.iter().enumerate() { - if m.role == Role::Admin { - admin_count = admin_count.saturating_add(1); - } - if &m.account == member { - target_idx = Some(i); - target_is_admin = m.role == Role::Admin; - } - } - (target_idx, target_is_admin, admin_count) - } - - fn ensure_writer_or_admin(who: &T::AccountId, bucket: &Bucket) -> DispatchResult { - ensure!( - bucket.members.iter().any(|m| &m.account == who - && (m.role == Role::Admin || m.role == Role::Writer)), - Error::::NotBucketWriter - ); - Ok(()) - } - - /// Reject any path that would create a new commitment for a - /// provider who has announced deregistration. `deregister_provider` - /// also flips `accepting_primary`/`accepting_extensions` to `false`, - fn ensure_provider_active(provider: &ProviderInfo) -> DispatchResult { - ensure!( - provider.deregister_at.is_none(), - Error::::DeregisterAnnounced - ); - Ok(()) - } - - /// Add or update a member's role on a bucket (callable from other pallets). - /// - /// The `caller` must be an Admin of the bucket. - pub fn set_member_internal( - caller: &T::AccountId, - bucket_id: BucketId, - member: T::AccountId, - role: Role, - ) -> DispatchResult { - Buckets::::try_mutate(bucket_id, |maybe_bucket| -> DispatchResult { - let bucket = maybe_bucket.as_mut().ok_or(Error::::BucketNotFound)?; - - Self::ensure_admin(caller, bucket)?; - - let (target_idx, target_is_admin, admin_count) = - Self::locate_member(bucket, &member); - if let Some(idx) = target_idx { - if target_is_admin && role != Role::Admin { - // Admins can only demote themselves, never another admin. - ensure!(member == *caller, Error::::CannotDemoteAdmin); - // And even self-demotion must leave at least one admin. - ensure!(admin_count > 1, Error::::LastAdminCannotBeRemoved); - } - bucket.members[idx].role = role; - } else { - let new_member = Member { - account: member.clone(), - role, - }; - bucket - .members - .try_push(new_member) - .map_err(|_| Error::::MaxMembersReached)?; - - MemberBuckets::::try_mutate(&member, |buckets| { - if !buckets.contains(&bucket_id) { - buckets - .try_push(bucket_id) - .map_err(|_| Error::::TooManyBucketsForMember) - } else { - Ok(()) - } - })?; - } - - Self::deposit_event(Event::MemberSet { - bucket_id, - member, - role, - }); - - Ok(()) - }) - } - - /// Remove a member from a bucket (callable from other pallets). - /// - /// The `caller` must be an Admin of the bucket. - pub fn remove_member_internal( - caller: &T::AccountId, - bucket_id: BucketId, - member: T::AccountId, - ) -> DispatchResult { - Buckets::::try_mutate(bucket_id, |maybe_bucket| -> DispatchResult { - let bucket = maybe_bucket.as_mut().ok_or(Error::::BucketNotFound)?; - - Self::ensure_admin(caller, bucket)?; - - let (target_idx, target_is_admin, admin_count) = - Self::locate_member(bucket, &member); - let member_idx = target_idx.ok_or(Error::::MemberNotFound)?; - - if target_is_admin { - // Admins can only remove themselves, never another admin. - ensure!(member == *caller, Error::::CannotDemoteAdmin); - // And even self-removal must leave at least one admin. - ensure!(admin_count > 1, Error::::LastAdminCannotBeRemoved); - } - - bucket.members.remove(member_idx); - - MemberBuckets::::mutate(&member, |buckets| { - buckets.retain(|id| *id != bucket_id); - }); - - Self::deposit_event(Event::MemberRemoved { bucket_id, member }); - - Ok(()) - }) - } - - fn validate_duration( - settings: &ProviderSettings, - duration: BlockNumberFor, - ) -> DispatchResult { - ensure!( - duration >= settings.min_duration, - Error::::DurationTooShort - ); - ensure!( - duration <= settings.max_duration, - Error::::DurationTooLong - ); - Ok(()) - } - - fn calculate_payment( - price_per_byte: BalanceOf, - max_bytes: u64, - duration: BlockNumberFor, - ) -> Result, DispatchError> { - // payment = price_per_byte * max_bytes * duration - // Use saturated_from for type conversions - let bytes_balance: BalanceOf = max_bytes.saturated_into(); - let duration_u128: u128 = duration.saturated_into(); - let duration_balance: BalanceOf = duration_u128.saturated_into(); - - price_per_byte - .checked_mul(&bytes_balance) - .and_then(|p| p.checked_mul(&duration_balance)) - .ok_or(Error::::ArithmeticOverflow.into()) - } - - fn finalize_agreement( - bucket_id: BucketId, - provider: &T::AccountId, - agreement: &StorageAgreement, - action: EndAction, - is_early: bool, - ) -> DispatchResult { - let (to_provider, to_burn) = match action { - EndAction::Pay => (agreement.payment_locked, Zero::zero()), - EndAction::Burn { burn_percent } => { - let burn_percent = burn_percent.min(100); - let burn_amount = - agreement.payment_locked * burn_percent.into() / 100u32.into(); - let pay_amount = agreement.payment_locked.saturating_sub(burn_amount); - (pay_amount, burn_amount) - } - }; - - // Unreserve from owner - T::Currency::unreserve(&agreement.owner, agreement.payment_locked); - - // Pay provider - if !to_provider.is_zero() { - T::Currency::transfer( - &agreement.owner, - provider, - to_provider, - ExistenceRequirement::KeepAlive, - )?; - } - - // Send burned amount to treasury - if !to_burn.is_zero() { - T::Currency::transfer( - &agreement.owner, - &T::Treasury::get(), - to_burn, - ExistenceRequirement::KeepAlive, - )?; - } - - // Update provider stats - Providers::::mutate(provider, |maybe_provider| { - if let Some(provider_info) = maybe_provider { - provider_info.committed_bytes = provider_info - .committed_bytes - .saturating_sub(agreement.max_bytes); - - if to_burn > Zero::zero() { - provider_info.stats.agreements_burned = - provider_info.stats.agreements_burned.saturating_add(1); - } else { - provider_info.stats.agreements_not_extended = provider_info - .stats - .agreements_not_extended - .saturating_add(1); - } - } - }); - - // Remove from primary_providers if primary - if matches!(agreement.role, ProviderRole::Primary) { - Buckets::::mutate(bucket_id, |maybe_bucket| { - if let Some(bucket) = maybe_bucket { - bucket.primary_providers.retain(|p| p != provider); - } - }); - - let reason = if is_early { - RemovalReason::AdminTerminated - } else { - RemovalReason::Expired - }; - - Self::deposit_event(Event::PrimaryProviderRemoved { - bucket_id, - provider: provider.clone(), - reason, - }); - } - - // Remove agreement - StorageAgreements::::remove(bucket_id, provider); - - Self::deposit_event(Event::AgreementEnded { - bucket_id, - provider: provider.clone(), - payment_to_provider: to_provider, - burned: to_burn, - }); - - Ok(()) - } - - /// Internal function to cleanup a bucket and all its agreements. - /// This is called by Layer 1 (drive-registry) when deleting a drive. - /// - /// Returns the total amount refunded to the owner. - pub fn cleanup_bucket_internal( - bucket_id: BucketId, - owner: &T::AccountId, - ) -> Result, DispatchError> { - // Verify bucket exists - let bucket = Buckets::::get(bucket_id).ok_or(Error::::BucketNotFound)?; - - // Verify caller is an admin of the bucket - Self::ensure_admin(owner, &bucket)?; - - let mut total_refunded: BalanceOf = Zero::zero(); - - // End all agreements for this bucket (pay providers fairly) - let agreements: Vec<_> = StorageAgreements::::iter_prefix(bucket_id).collect(); - - for (provider, agreement) in agreements { - // Calculate prorated refund based on remaining time - let current_block = frame_system::Pallet::::block_number(); - let remaining_blocks = agreement.expires_at.saturating_sub(current_block); - - // If there's remaining time, calculate prorated refund - let refund_to_owner = if remaining_blocks > Zero::zero() { - let total_duration = agreement.expires_at.saturating_sub(agreement.started_at); - if total_duration > Zero::zero() { - let remaining_u128: u128 = remaining_blocks.saturated_into(); - let total_u128: u128 = total_duration.saturated_into(); - let payment_u128: u128 = agreement.payment_locked.saturated_into(); - - // refund = payment * (remaining / total) - let refund_u128 = payment_u128 - .saturating_mul(remaining_u128) - .saturating_div(total_u128); - refund_u128.saturated_into() - } else { - Zero::zero() - } - } else { - Zero::zero() - }; - - // Payment to provider = total locked - refund to owner - let payment_to_provider = agreement.payment_locked.saturating_sub(refund_to_owner); - - // Unreserve from owner - T::Currency::unreserve(&agreement.owner, agreement.payment_locked); - - // Pay provider their earned portion - if !payment_to_provider.is_zero() { - T::Currency::transfer( - &agreement.owner, - &provider, - payment_to_provider, - ExistenceRequirement::KeepAlive, - )?; - } - - // Track total refunded (owner keeps the unspent portion) - total_refunded = total_refunded.saturating_add(refund_to_owner); - - // Update provider stats - Providers::::mutate(&provider, |maybe_provider| { - if let Some(provider_info) = maybe_provider { - provider_info.committed_bytes = provider_info - .committed_bytes - .saturating_sub(agreement.max_bytes); - provider_info.stats.agreements_not_extended = provider_info - .stats - .agreements_not_extended - .saturating_add(1); - } - }); - - // Remove agreement - StorageAgreements::::remove(bucket_id, &provider); - - Self::deposit_event(Event::AgreementEnded { - bucket_id, - provider: provider.clone(), - payment_to_provider, - burned: Zero::zero(), - }); - } - - // Clean up reverse index for all members - for member in &bucket.members { - MemberBuckets::::mutate(&member.account, |buckets| { - buckets.retain(|id| *id != bucket_id); - }); - } - - // Remove the bucket itself - Buckets::::remove(bucket_id); - - Self::deposit_event(Event::BucketDeleted { bucket_id }); - - Ok(total_refunded) - } - - fn create_challenge( - challenger: T::AccountId, - bucket_id: BucketId, - provider: T::AccountId, - mmr_root: H256, - start_seq: u64, - leaf_index: u64, - chunk_index: u64, - ) -> DispatchResult { - // Calculate deposit (simplified - would be based on expected costs) - let deposit: BalanceOf = 100u32.into(); - - T::Currency::reserve(&challenger, deposit)?; - - let current_block = frame_system::Pallet::::block_number(); - let deadline = current_block.saturating_add(T::ChallengeTimeout::get()); - - let challenge = Challenge { - bucket_id, - provider: provider.clone(), - challenger: challenger.clone(), - mmr_root, - start_seq, - leaf_index, - chunk_index, - deposit, - }; - - let index = Challenges::::mutate(deadline, |challenges| { - let challenges = challenges.get_or_insert_with(Vec::new); - let idx = challenges.len() as u16; - challenges.push(challenge); - idx - }); - - // Update provider stats - Providers::::mutate(&provider, |maybe_provider| { - if let Some(provider_info) = maybe_provider { - provider_info.stats.challenges_received = - provider_info.stats.challenges_received.saturating_add(1); - } - }); - - let challenge_id = ChallengeId { deadline, index }; - - Self::deposit_event(Event::ChallengeCreated { - challenge_id, - bucket_id, - provider, - challenger, - respond_by: deadline, - }); - - Ok(()) - } - - fn update_historical_roots( - bucket: &mut Bucket, - current_block: BlockNumberFor, - mmr_root: H256, - ) { - let block_num: u32 = current_block.try_into().unwrap_or(0u32); - - for (i, &prime) in HISTORICAL_ROOT_PRIMES.iter().enumerate() { - let quotient = block_num / prime; - if quotient != bucket.historical_roots[i].0 { - bucket.historical_roots[i] = (quotient, mmr_root); - } - } - } - - fn find_matching_root( - bucket: &Bucket, - roots: &[Option; 7], - ) -> Result<(u8, H256), DispatchError> { - // Check current snapshot first - if let (Some(snapshot), Some(root)) = (&bucket.snapshot, roots[0]) { - if snapshot.mmr_root == root { - return Ok((0, root)); - } - } - - // Check historical roots - for i in 0..6 { - if let Some(root) = roots[i + 1] { - if bucket.historical_roots[i].1 == root { - return Ok((i as u8 + 1, root)); - } - } - } - - Err(Error::::InvalidSyncRoot.into()) - } - - /// Slash a provider who failed to respond to a challenge. - /// - /// This: - /// 1. Slashes the provider's entire stake - /// 2. Refunds the challenger with their deposit plus a reward - /// 3. Updates provider statistics - /// 4. Marks the provider as slashed (so they can be removed from buckets) - fn slash_provider_for_failed_challenge( - challenge: &Challenge, - challenge_id: ChallengeId>, - ) { - // Get provider info - if let Some(mut provider_info) = Providers::::get(&challenge.provider) { - // Slash the provider's entire stake - let slashed_amount = provider_info.stake; - - // Unreserve and slash the stake - // In Substrate, slashing typically burns or sends to treasury - let (_, remaining) = - T::Currency::slash_reserved(&challenge.provider, slashed_amount); - let actually_slashed = slashed_amount.saturating_sub(remaining); - - // Calculate challenger reward (e.g., 10% of slashed amount, rest goes to treasury) - let challenger_reward = actually_slashed / 10u32.into(); - let to_treasury = actually_slashed.saturating_sub(challenger_reward); - - // Refund challenger's deposit - T::Currency::unreserve(&challenge.challenger, challenge.deposit); - - // Transfer reward to challenger - // Note: We need to handle potential errors gracefully in on_finalize - let _ = T::Currency::deposit_creating(&challenge.challenger, challenger_reward); - - // The rest goes to treasury (burned by slash_reserved) - let _ = to_treasury; // Acknowledged - - // Update provider stats - provider_info.stats.challenges_failed = - provider_info.stats.challenges_failed.saturating_add(1); - provider_info.stake = Zero::zero(); - - Providers::::insert(&challenge.provider, provider_info); - - // Emit event - Self::deposit_event(Event::ChallengeSlashed { - challenge_id, - provider: challenge.provider.clone(), - slashed_amount: actually_slashed, - challenger_reward, - }); - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Provider-Initiated Checkpoint Helpers - // ───────────────────────────────────────────────────────────────────────── - - /// Calculate the checkpoint window number for a given block. - /// - /// Window 0 starts at block 0, window 1 at block `interval`, etc. - fn calculate_window(block: BlockNumberFor, interval: BlockNumberFor) -> u64 { - if interval.is_zero() { - return 0; - } - let block_num: u64 = block.saturated_into(); - let interval_num: u64 = interval.saturated_into(); - block_num / interval_num - } - - /// Calculate the start block for a given checkpoint window. - fn window_start_block(window: u64, interval: BlockNumberFor) -> BlockNumberFor { - let interval_num: u64 = interval.saturated_into(); - let start: u64 = window.saturating_mul(interval_num); - start.saturated_into() - } - - /// Calculate the leader index for a given bucket and window. - /// - /// Uses deterministic selection: blake2_256(bucket_id || window) % num_providers. - /// This ensures all providers can independently calculate who the leader is. - fn calculate_leader_index(bucket_id: BucketId, window: u64, num_providers: u32) -> u32 { - if num_providers == 0 { - return 0; - } - // Create deterministic seed from bucket_id and window - let mut data = [0u8; 16]; - data[..8].copy_from_slice(&bucket_id.to_le_bytes()); - data[8..].copy_from_slice(&window.to_le_bytes()); - let hash = sp_io::hashing::blake2_256(&data); - // Take first 4 bytes as u32 and mod by num_providers - let seed = u32::from_le_bytes([hash[0], hash[1], hash[2], hash[3]]); - seed % num_providers - } - - /// Get the checkpoint config for a bucket, falling back to defaults. - fn get_checkpoint_config( - bucket_id: BucketId, - ) -> storage_primitives::CheckpointWindowConfig> { - CheckpointConfigs::::get(bucket_id).unwrap_or_else(|| { - storage_primitives::CheckpointWindowConfig { - interval: T::DefaultCheckpointInterval::get(), - grace_period: T::DefaultCheckpointGrace::get(), - enabled: true, // Enabled by default - } - }) - } - - /// Check if the current block is within the grace period for a window. - fn is_within_grace_period( - current_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 - } - - // ───────────────────────────────────────────────────────────────────────── - // Runtime API Implementation - // ───────────────────────────────────────────────────────────────────────── - - /// Query provider information. - pub fn query_provider_info( - provider: &T::AccountId, - ) -> Option { - Providers::::get(provider).map(|info| { - let max_capacity = info.settings.max_capacity; - let available_capacity = if max_capacity > 0 { - Some(max_capacity.saturating_sub(info.committed_bytes)) - } else { - None // Unlimited - }; - - crate::runtime_api::ProviderInfoResponse { - multiaddr: info.multiaddr.to_vec(), - public_key: info.public_key.to_vec(), - stake: info.stake.saturated_into::(), - committed_bytes: info.committed_bytes, - min_duration: info.settings.min_duration.saturated_into::(), - max_duration: info.settings.max_duration.saturated_into::(), - price_per_byte: info.settings.price_per_byte.saturated_into::(), - accepting_primary: info.settings.accepting_primary, - replica_sync_price: info - .settings - .replica_sync_price - .map(|p| p.saturated_into::()), - accepting_extensions: info.settings.accepting_extensions, - registered_at: info.stats.registered_at.saturated_into::(), - agreements_total: info.stats.agreements_total, - agreements_extended: info.stats.agreements_extended, - agreements_not_extended: info.stats.agreements_not_extended, - agreements_burned: info.stats.agreements_burned, - challenges_received: info.stats.challenges_received, - challenges_failed: info.stats.challenges_failed, - max_capacity, - available_capacity, - } - }) - } - - /// Query all providers (paginated). - pub fn query_providers( - offset: u32, - limit: u32, - ) -> Vec<(T::AccountId, crate::runtime_api::ProviderInfoResponse)> { - Providers::::iter() - .skip(offset as usize) - .take(limit as usize) - .map(|(account, info)| { - let max_capacity = info.settings.max_capacity; - let available_capacity = if max_capacity > 0 { - Some(max_capacity.saturating_sub(info.committed_bytes)) - } else { - None // Unlimited - }; - - ( - account, - crate::runtime_api::ProviderInfoResponse { - multiaddr: info.multiaddr.to_vec(), - public_key: info.public_key.to_vec(), - stake: info.stake.saturated_into::(), - committed_bytes: info.committed_bytes, - min_duration: info.settings.min_duration.saturated_into::(), - max_duration: info.settings.max_duration.saturated_into::(), - price_per_byte: info.settings.price_per_byte.saturated_into::(), - accepting_primary: info.settings.accepting_primary, - replica_sync_price: info - .settings - .replica_sync_price - .map(|p| p.saturated_into::()), - accepting_extensions: info.settings.accepting_extensions, - registered_at: info.stats.registered_at.saturated_into::(), - agreements_total: info.stats.agreements_total, - agreements_extended: info.stats.agreements_extended, - agreements_not_extended: info.stats.agreements_not_extended, - agreements_burned: info.stats.agreements_burned, - challenges_received: info.stats.challenges_received, - challenges_failed: info.stats.challenges_failed, - max_capacity, - available_capacity, - }, - ) - }) - .collect() - } - - /// Query bucket information. - pub fn query_bucket_info( - bucket_id: BucketId, - ) -> Option { - Buckets::::get(bucket_id).map(|bucket| crate::runtime_api::BucketResponse { - bucket_id, - members: bucket - .members - .iter() - .map(|m| crate::runtime_api::BucketMemberResponse { - account: m.account.encode(), - role: m.role, - }) - .collect(), - frozen_start_seq: bucket.frozen_start_seq, - min_providers: bucket.min_providers, - primary_providers: bucket - .primary_providers - .iter() - .map(|p| p.encode()) - .collect(), - snapshot: bucket.snapshot.map(|s| BucketSnapshot { - mmr_root: s.mmr_root, - start_seq: s.start_seq, - leaf_count: s.leaf_count, - checkpoint_block: s.checkpoint_block.saturated_into::(), - primary_signers: s.primary_signers.clone(), - }), - total_snapshots: bucket.total_snapshots, - }) - } - - /// Query bucket providers. - pub fn query_bucket_providers(bucket_id: BucketId) -> Vec { - Buckets::::get(bucket_id) - .map(|bucket| bucket.primary_providers.to_vec()) - .unwrap_or_default() - } - - /// Query agreement information. - pub fn query_agreement_info( - bucket_id: BucketId, - provider: &T::AccountId, - ) -> Option { - StorageAgreements::::get(bucket_id, provider).map(|agreement| { - crate::runtime_api::AgreementResponse { - bucket_id, - owner: agreement.owner.encode(), - provider: provider.encode(), - max_bytes: agreement.max_bytes, - payment_locked: agreement.payment_locked.saturated_into::(), - price_per_byte: agreement.price_per_byte.saturated_into::(), - expires_at: agreement.expires_at.saturated_into::(), - extensions_blocked: agreement.extensions_blocked, - role: match agreement.role { - ProviderRole::Primary => ProviderRole::Primary, - ProviderRole::Replica { - sync_balance, - sync_price, - min_sync_interval, - last_sync, - } => ProviderRole::Replica { - sync_balance: sync_balance.saturated_into::(), - sync_price: sync_price.saturated_into::(), - min_sync_interval: min_sync_interval.saturated_into::(), - last_sync: last_sync - .map(|(root, block)| (root, block.saturated_into::())), - }, - }, - started_at: agreement.started_at.saturated_into::(), - } - }) - } - - /// Query all agreements for a bucket. - pub fn query_bucket_agreements( - bucket_id: BucketId, - ) -> Vec { - StorageAgreements::::iter_prefix(bucket_id) - .map( - |(provider, agreement)| crate::runtime_api::AgreementResponse { - bucket_id, - owner: agreement.owner.encode(), - provider: provider.encode(), - max_bytes: agreement.max_bytes, - payment_locked: agreement.payment_locked.saturated_into::(), - price_per_byte: agreement.price_per_byte.saturated_into::(), - expires_at: agreement.expires_at.saturated_into::(), - extensions_blocked: agreement.extensions_blocked, - role: match agreement.role { - ProviderRole::Primary => ProviderRole::Primary, - ProviderRole::Replica { - sync_balance, - sync_price, - min_sync_interval, - last_sync, - } => ProviderRole::Replica { - sync_balance: sync_balance.saturated_into::(), - sync_price: sync_price.saturated_into::(), - min_sync_interval: min_sync_interval.saturated_into::(), - last_sync: last_sync - .map(|(root, block)| (root, block.saturated_into::())), - }, - }, - started_at: agreement.started_at.saturated_into::(), - }, - ) - .collect() - } - - /// Query all bucket IDs (paginated). - pub fn query_bucket_ids(offset: u32, limit: u32) -> Vec { - Buckets::::iter_keys() - .skip(offset as usize) - .take(limit as usize) - .collect() - } - - /// Query all agreements for a provider. - pub fn query_provider_agreements( - provider: &T::AccountId, - ) -> Vec { - StorageAgreements::::iter() - .filter(|(_, p, _)| p == provider) - .map( - |(bucket_id, _, agreement)| crate::runtime_api::AgreementResponse { - bucket_id, - owner: agreement.owner.encode(), - provider: provider.encode(), - max_bytes: agreement.max_bytes, - payment_locked: agreement.payment_locked.saturated_into::(), - price_per_byte: agreement.price_per_byte.saturated_into::(), - expires_at: agreement.expires_at.saturated_into::(), - extensions_blocked: agreement.extensions_blocked, - role: match agreement.role { - ProviderRole::Primary => ProviderRole::Primary, - ProviderRole::Replica { - sync_balance, - sync_price, - min_sync_interval, - last_sync, - } => ProviderRole::Replica { - sync_balance: sync_balance.saturated_into::(), - sync_price: sync_price.saturated_into::(), - min_sync_interval: min_sync_interval.saturated_into::(), - last_sync: last_sync - .map(|(root, block)| (root, block.saturated_into::())), - }, - }, - started_at: agreement.started_at.saturated_into::(), - }, - ) - .collect() - } - - /// Query challenges expiring at a specific block. - pub fn query_challenges_at( - block: BlockNumberFor, - ) -> Vec { - Challenges::::get(block) - .unwrap_or_default() - .into_iter() - .enumerate() - .map(|(idx, challenge)| crate::runtime_api::ChallengeResponse { - bucket_id: challenge.bucket_id, - provider: challenge.provider.encode(), - challenger: challenge.challenger.encode(), - mmr_root: challenge.mmr_root, - start_seq: challenge.start_seq, - leaf_index: challenge.leaf_index, - chunk_index: challenge.chunk_index, - deadline: block.saturated_into::(), - index: idx as u16, - deposit: challenge.deposit.saturated_into::(), - }) - .collect() - } - - /// Query all challenges for a specific bucket. - pub fn query_bucket_challenges( - bucket_id: BucketId, - ) -> Vec { - Challenges::::iter() - .flat_map(|(block, challenges)| { - let deadline: u32 = block.saturated_into::(); - challenges - .into_iter() - .enumerate() - .map( - move |(idx, challenge)| crate::runtime_api::ChallengeResponse { - bucket_id: challenge.bucket_id, - provider: challenge.provider.encode(), - challenger: challenge.challenger.encode(), - mmr_root: challenge.mmr_root, - start_seq: challenge.start_seq, - leaf_index: challenge.leaf_index, - chunk_index: challenge.chunk_index, - deadline, - index: idx as u16, - deposit: challenge.deposit.saturated_into::(), - }, - ) - }) - .filter(|c| c.bucket_id == bucket_id) - .collect() - } - - /// Query all challenges targeting a specific provider. - pub fn query_provider_challenges( - provider: &T::AccountId, - ) -> Vec { - let provider_encoded = provider.encode(); - Challenges::::iter() - .flat_map(|(block, challenges)| { - let deadline: u32 = block.saturated_into::(); - challenges - .into_iter() - .enumerate() - .map( - move |(idx, challenge)| crate::runtime_api::ChallengeResponse { - bucket_id: challenge.bucket_id, - provider: challenge.provider.encode(), - challenger: challenge.challenger.encode(), - mmr_root: challenge.mmr_root, - start_seq: challenge.start_seq, - leaf_index: challenge.leaf_index, - chunk_index: challenge.chunk_index, - deadline, - index: idx as u16, - deposit: challenge.deposit.saturated_into::(), - }, - ) - }) - .filter(|c| c.provider == provider_encoded) - .collect() - } - - /// Query all challenges created by a specific challenger. - pub fn query_challenger_challenges( - challenger: &T::AccountId, - ) -> Vec { - let challenger_encoded = challenger.encode(); - Challenges::::iter() - .flat_map(|(block, challenges)| { - let deadline: u32 = block.saturated_into::(); - challenges - .into_iter() - .enumerate() - .map( - move |(idx, challenge)| crate::runtime_api::ChallengeResponse { - bucket_id: challenge.bucket_id, - provider: challenge.provider.encode(), - challenger: challenge.challenger.encode(), - mmr_root: challenge.mmr_root, - start_seq: challenge.start_seq, - leaf_index: challenge.leaf_index, - chunk_index: challenge.chunk_index, - deadline, - index: idx as u16, - deposit: challenge.deposit.saturated_into::(), - }, - ) - }) - .filter(|c| c.challenger == challenger_encoded) - .collect() - } - - /// Check if provider can accept additional bytes. - pub fn query_can_accept_bytes(provider: &T::AccountId, additional_bytes: u64) -> bool { - if let Some(provider_info) = Providers::::get(provider) { - let new_committed_bytes = provider_info - .committed_bytes - .saturating_add(additional_bytes); - - // Check capacity constraint - if provider_info.settings.max_capacity > 0 - && new_committed_bytes > provider_info.settings.max_capacity - { - return false; - } - - let bytes_as_balance: BalanceOf = new_committed_bytes.saturated_into(); - - if let Some(required_stake) = - T::MinStakePerByte::get().checked_mul(&bytes_as_balance) - { - return provider_info.stake >= required_stake; - } - } - false - } - - /// Validate provider settings against committed bytes and stake. - /// - /// Shared by `update_provider_settings` and `register_provider_internal`. - fn validate_settings( - settings: &ProviderSettings, - committed_bytes: u64, - stake: BalanceOf, - ) -> DispatchResult { - ensure!( - settings.min_duration <= settings.max_duration, - Error::::MinDurationExceedsMaxDuration - ); - - // Validate max_capacity >= committed_bytes (unless 0 = unlimited) - if settings.max_capacity > 0 { - ensure!( - settings.max_capacity >= committed_bytes, - Error::::CapacityBelowCommitted - ); - - // Validate stake backs declared capacity - use sp_runtime::traits::SaturatedConversion; - let capacity_as_balance: BalanceOf = settings.max_capacity.saturated_into(); - let required_stake = T::MinStakePerByte::get() - .checked_mul(&capacity_as_balance) - .ok_or(Error::::ArithmeticOverflow)?; - ensure!( - stake >= required_stake, - Error::::InsufficientStakeForCapacity - ); - } - - Ok(()) - } - - /// Register a provider, reserving their stake. - /// - /// Shared by the `register_provider` extrinsic (which passes - /// `ProviderSettings::default()`) and genesis build (which passes the - /// full settings, since post-genesis there is no one to call - /// `update_provider_settings`). - pub(crate) fn register_provider_internal( - who: &T::AccountId, - multiaddr: BoundedVec, - public_key: BoundedVec>, - stake: BalanceOf, - settings: ProviderSettings, - ) -> DispatchResult { - ensure!( - !Providers::::contains_key(who), - Error::::ProviderAlreadyRegistered - ); - ensure!( - stake >= T::MinProviderStake::get(), - Error::::InsufficientStake - ); - - // Validate public key length (32 bytes for Sr25519/Ed25519, 33 for Ecdsa compressed) - let key_len = public_key.len(); - ensure!( - key_len == 32 || key_len == 33 || key_len == 64, - Error::::InvalidPublicKey - ); - - Self::validate_settings(&settings, 0, stake)?; - - // Reserve stake - T::Currency::reserve(who, stake)?; - - let current_block = frame_system::Pallet::::block_number(); - - let provider_info = ProviderInfo { - multiaddr, - public_key, - stake, - committed_bytes: 0, - settings, - stats: ProviderStats { - registered_at: current_block, - ..Default::default() - }, - deregister_at: None, - }; - - Providers::::insert(who, provider_info); - ProviderReplayStates::::insert(who, ReplayWindow::default()); - - Self::deposit_event(Event::ProviderRegistered { - provider: who.clone(), - stake, - }); - - Ok(()) - } - - // ───────────────────────────────────────────────────────────────────────── - // Internal Functions for Inter-Pallet Communication (Layer 1 File System) - // ───────────────────────────────────────────────────────────────────────── - - /// Create a bucket internally (for use by other pallets like Layer 1 File System). - /// - /// This bypasses the normal extrinsic flow and creates a bucket directly, - /// with the specified account as admin. - /// - /// Parameters: - /// - `admin`: Account that will be the bucket admin. - /// - `min_providers`: Minimum number of primary providers required to - /// sign each checkpoint. - /// - `initial_primary`: Optional provider to seed as the bucket's - /// first `primary_providers` entry. Used by - /// `establish_storage_agreement_internal` to atomically create the - /// bucket together with its primary agreement; pass `None` for - /// buckets that will register primaries later. - /// - /// Returns: bucket_id - pub fn create_bucket_internal( - admin: &T::AccountId, - min_providers: u32, - initial_primary: Option<&T::AccountId>, - ) -> Result { - let bucket_id = NextBucketId::::get(); - NextBucketId::::put(bucket_id.saturating_add(1)); - - let admin_member = Member { - account: admin.clone(), - role: Role::Admin, - }; - - let mut members = BoundedVec::new(); - members - .try_push(admin_member) - .map_err(|_| Error::::MaxMembersReached)?; - - let mut primary_providers = BoundedVec::new(); - if let Some(p) = initial_primary { - primary_providers - .try_push(p.clone()) - .map_err(|_| Error::::MaxPrimaryProvidersReached)?; - } - - let bucket = Bucket { - members, - frozen_start_seq: None, - min_providers, - primary_providers, - snapshot: None, - historical_roots: [(0, H256::zero()); 6], - total_snapshots: 0, - }; - - Buckets::::insert(bucket_id, bucket); - - // Update reverse index for creator - MemberBuckets::::try_mutate(admin, |buckets| { - buckets - .try_push(bucket_id) - .map_err(|_| Error::::TooManyBucketsForMember) - })?; - - Self::deposit_event(Event::BucketCreated { - bucket_id, - admin: admin.clone(), - }); - - Ok(bucket_id) - } - - /// Redeem provider-signed terms (used directly by the - /// `establish_storage_agreement` extrinsic and by higher-layer pallets that - /// fold bucket creation into their own flows). - /// - /// Verifies the signature, advances the provider's replay window, - /// then runs the same provider/capacity/stake checks as - /// `create_bucket_with_storage` before creating the bucket + primary - /// agreement. - pub fn establish_storage_agreement_internal( - owner: &T::AccountId, - provider: &T::AccountId, - terms: AgreementTermsOf, - sig: &sp_runtime::MultiSignature, - ) -> Result { - // Origin must match the owner the provider signed for. - ensure!(&terms.owner == owner, Error::::TermsOwnerMismatch); - - // Primary terms must not be bound to an existing bucket — the - // bucket is created at redemption. - ensure!(terms.bucket_id.is_none(), Error::::TermsBucketMismatch); - - // Request's terms.max_bytes must greater than 0 - 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 = frame_system::Pallet::::block_number(); - ensure!(terms.valid_until >= current_block, Error::::TermsExpired); - ensure!( - terms.valid_until <= current_block.saturating_add(T::RequestTimeout::get()), - Error::::TermsValidityTooLong - ); - - // Provider lookup + signature check over - // blake2_256(PRIMARY_TERM_CONTEXT | SCALE(terms)). - let provider_info = - Providers::::get(provider).ok_or(Error::::ProviderNotFound)?; - Self::verify_terms_signature( - &provider_info, - &terms, - sig, - storage_primitives::PRIMARY_TERM_CONTEXT, - )?; - - // Replay window: at most once per nonce, within the trailing REPLAY_WINDOW_BITS slots. - ProviderReplayStates::::try_mutate(provider, |window| -> DispatchResult { - window.try_accept(terms.nonce).map_err(|e| match e { - ReplayError::AlreadyUsed => Error::::NonceAlreadyUsed, - ReplayError::TooOld => Error::::NonceTooOld, - })?; - Ok(()) - })?; - - // Validate on-chain provider's state then create bucket - Self::ensure_provider_active(&provider_info)?; - ensure!( - provider_info.settings.accepting_primary, - Error::::ProviderNotAcceptingPrimary - ); - Self::validate_duration(&provider_info.settings, terms.duration)?; - - let new_committed = provider_info - .committed_bytes - .checked_add(terms.max_bytes) - .ok_or(Error::::ArithmeticOverflow)?; - if provider_info.settings.max_capacity > 0 { - ensure!( - new_committed <= provider_info.settings.max_capacity, - Error::::CapacityExceeded - ); - } - - { - let bytes_as_balance: BalanceOf = new_committed.saturated_into(); - let required_stake = T::MinStakePerByte::get() - .checked_mul(&bytes_as_balance) - .ok_or(Error::::ArithmeticOverflow)?; - ensure!( - provider_info.stake >= required_stake, - Error::::InsufficientStakeForBytes - ); - } - - // Pay at the price the provider signed for. - let payment = - Self::calculate_payment(terms.price_per_byte, terms.max_bytes, terms.duration)?; - T::Currency::reserve(owner, payment)?; - - // Bucket creation folded in: owner is sole admin, provider is the - // bucket's single primary. `create_bucket_internal` emits - // `BucketCreated` for us. - let bucket_id = Self::create_bucket_internal(owner, 1, Some(provider))?; - - let expires_at = current_block.saturating_add(terms.duration); - let agreement = StorageAgreement { - owner: owner.clone(), - max_bytes: terms.max_bytes, - payment_locked: payment, - price_per_byte: terms.price_per_byte, - expires_at, - extensions_blocked: false, - role: ProviderRole::Primary, - started_at: current_block, - }; - - Providers::::mutate(provider, |maybe_provider| { - if let Some(p) = maybe_provider { - p.committed_bytes = new_committed; - p.stats.agreements_total = p.stats.agreements_total.saturating_add(1); - p.stats.total_bytes_committed = p - .stats - .total_bytes_committed - .saturating_add(terms.max_bytes); - } - }); - StorageAgreements::::insert(bucket_id, provider, agreement); - - Self::deposit_event(Event::StorageAgreementEstablished { - bucket_id, - provider: provider.clone(), - owner: owner.clone(), - terms, - expires_at, - }); - - Ok(bucket_id) - } - - /// Redeem provider-signed terms for a replica agreement (used directly - /// by the `establish_replica_agreement` extrinsic and by higher-layer - /// pallets that fold replica establishment into their own flows). - /// - /// Verifies the signature, advances the provider's replay window, then - /// runs the provider/capacity/stake checks before opening the replica - /// agreement on an existing bucket. `terms.replica_params` must be - /// `Some(_)`. - pub fn establish_replica_agreement_internal( - owner: &T::AccountId, - bucket_id: BucketId, - provider: &T::AccountId, - terms: AgreementTermsOf, - sig: &sp_runtime::MultiSignature, - ) -> DispatchResult { - // Origin must match the owner the provider signed for. - ensure!(&terms.owner == owner, Error::::TermsOwnerMismatch); - - // The provider's signed quote must be bound to the bucket this - // extrinsic targets. - ensure!( - terms.bucket_id == Some(bucket_id), - Error::::TermsBucketMismatch - ); - - // Request's terms.max_bytes must greater than 0 - 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(); - ensure!(terms.valid_until >= current_block, Error::::TermsExpired); - ensure!( - terms.valid_until <= current_block.saturating_add(T::RequestTimeout::get()), - Error::::TermsValidityTooLong - ); - - // Target bucket must exist. - ensure!( - Buckets::::contains_key(bucket_id), - Error::::BucketNotFound - ); - - // No existing agreement for (bucket, provider). - ensure!( - !StorageAgreements::::contains_key(bucket_id, provider), - Error::::AgreementAlreadyExists - ); - - // Replica terms must be present for a replica agreement. - let replica_terms = terms - .replica_params - .as_ref() - .ok_or(Error::::MissingReplicaTerms)? - .clone(); - - // Provider lookup + signature check over - // blake2_256(REPLICA_TERM_CONTEXT | SCALE(terms)). - let provider_info = - Providers::::get(provider).ok_or(Error::::ProviderNotFound)?; - Self::verify_terms_signature( - &provider_info, - &terms, - sig, - storage_primitives::REPLICA_TERM_CONTEXT, - )?; - - // Replay window: at most once per nonce, within the trailing REPLAY_WINDOW_BITS slots. - ProviderReplayStates::::try_mutate(provider, |window| -> DispatchResult { - window.try_accept(terms.nonce).map_err(|e| match e { - ReplayError::AlreadyUsed => Error::::NonceAlreadyUsed, - ReplayError::TooOld => Error::::NonceTooOld, - })?; - Ok(()) - })?; - - // Validate on-chain provider's state. - Self::ensure_provider_active(&provider_info)?; - // Provider is no longer accept replica node - let _ = provider_info - .settings - .replica_sync_price - .ok_or(Error::::ProviderNotAcceptingReplicas)?; - Self::validate_duration(&provider_info.settings, terms.duration)?; - - let new_committed = provider_info - .committed_bytes - .checked_add(terms.max_bytes) - .ok_or(Error::::ArithmeticOverflow)?; - if provider_info.settings.max_capacity > 0 { - ensure!( - new_committed <= provider_info.settings.max_capacity, - Error::::CapacityExceeded - ); - } - - { - let bytes_as_balance: BalanceOf = new_committed.saturated_into(); - let required_stake = T::MinStakePerByte::get() - .checked_mul(&bytes_as_balance) - .ok_or(Error::::ArithmeticOverflow)?; - ensure!( - provider_info.stake >= required_stake, - Error::::InsufficientStakeForBytes - ); - } - - // Pay at the price the provider signed for, plus the sync balance. - let payment = - Self::calculate_payment(terms.price_per_byte, terms.max_bytes, terms.duration)?; - let total_lock = payment - .checked_add(&replica_terms.sync_balance) - .ok_or(Error::::ArithmeticOverflow)?; - T::Currency::reserve(owner, total_lock)?; - - let expires_at = current_block.saturating_add(terms.duration); - let agreement = StorageAgreement { - owner: owner.clone(), - max_bytes: terms.max_bytes, - payment_locked: payment, - price_per_byte: terms.price_per_byte, - expires_at, - extensions_blocked: false, - role: ProviderRole::Replica { - sync_balance: replica_terms.sync_balance, - sync_price: replica_terms.sync_price, - min_sync_interval: replica_terms.min_sync_interval, - last_sync: None, - }, - started_at: current_block, - }; - - Providers::::mutate(provider, |maybe_provider| { - if let Some(p) = maybe_provider { - p.committed_bytes = new_committed; - p.stats.agreements_total = p.stats.agreements_total.saturating_add(1); - p.stats.total_bytes_committed = p - .stats - .total_bytes_committed - .saturating_add(terms.max_bytes); - } - }); - StorageAgreements::::insert(bucket_id, provider, agreement); - - Self::deposit_event(Event::ReplicaAgreementEstablished { - bucket_id, - provider: provider.clone(), - owner: owner.clone(), - terms, - expires_at, - }); - - Ok(()) - } - - /// Query available providers that can accept storage of given size - /// - /// This is a helper for Layer 1 to find suitable providers automatically. - /// - /// Parameters: - /// - `max_bytes`: Storage size needed - /// - `accepting_primary`: True to filter for primary providers, false for replica providers - /// - /// Returns: Vec of provider account IDs that can accept the storage - pub fn query_available_providers( - max_bytes: u64, - accepting_primary: bool, - ) -> Vec { - Providers::::iter() - .filter_map(|(account, info)| { - // Check if provider is accepting the right type of agreements - let accepts_type = if accepting_primary { - info.settings.accepting_primary - } else { - info.settings.replica_sync_price.is_some() - }; - - if !accepts_type { - return None; - } - - // Check if provider has capacity - if Self::query_can_accept_bytes(&account, max_bytes) { - Some(account) - } else { - None - } - }) - .collect() - } - - // ───────────────────────────────────────────────────────────────────────── - // Marketplace Query Functions (Provider Discovery) - // ───────────────────────────────────────────────────────────────────────── - - /// Find providers matching the given storage requirements. - pub fn query_find_matching_providers( - requirements: crate::runtime_api::StorageRequirements, - limit: u32, - ) -> Vec { - use crate::runtime_api::{MatchedProvider, PartialMatchReason}; - - let mut results: Vec = Vec::new(); - - for (account, info) in Providers::::iter() { - // Skip providers that have announced deregistration — they are - // winding down and must not be offered for new agreements. - if info.deregister_at.is_some() { - continue; - } - - let max_capacity = info.settings.max_capacity; - let available = if max_capacity > 0 { - max_capacity.saturating_sub(info.committed_bytes) - } else { - u64::MAX // Unlimited - }; - - let price: u128 = info.settings.price_per_byte.saturated_into(); - let min_dur: u32 = info.settings.min_duration.saturated_into(); - let max_dur: u32 = info.settings.max_duration.saturated_into(); - - // Determine match score and partial reason - let mut score: u8 = 100; - let mut partial_reason: Option = None; - - // Check accepting status - // Primary required: must accept primary - // Replica acceptable: must accept primary OR have replica sync price - let not_accepting = if requirements.primary_only { - !info.settings.accepting_primary - } else { - !info.settings.accepting_primary && info.settings.replica_sync_price.is_none() - }; - if not_accepting { - score = 0; - partial_reason = Some(PartialMatchReason::NotAccepting); - } - - // Check capacity - if score > 0 && available < requirements.bytes_needed { - score = score.saturating_sub(50); - if partial_reason.is_none() { - partial_reason = Some(PartialMatchReason::InsufficientCapacity); - } - } - - // Check price - if score > 0 && price > requirements.max_price_per_byte { - score = score.saturating_sub(30); - if partial_reason.is_none() { - partial_reason = Some(PartialMatchReason::PriceTooHigh); - } - } - - // Check duration - if score > 0 - && (requirements.min_duration < min_dur || requirements.min_duration > max_dur) - { - score = score.saturating_sub(20); - if partial_reason.is_none() { - partial_reason = Some(PartialMatchReason::DurationMismatch); - } - } - - // Build the available_capacity field - let available_capacity = if max_capacity > 0 { - Some(available) - } else { - None - }; - - let provider_response = crate::runtime_api::ProviderInfoResponse { - multiaddr: info.multiaddr.to_vec(), - public_key: info.public_key.to_vec(), - stake: info.stake.saturated_into::(), - committed_bytes: info.committed_bytes, - min_duration: min_dur, - max_duration: max_dur, - price_per_byte: price, - accepting_primary: info.settings.accepting_primary, - replica_sync_price: info - .settings - .replica_sync_price - .map(|p| p.saturated_into::()), - accepting_extensions: info.settings.accepting_extensions, - registered_at: info.stats.registered_at.saturated_into::(), - agreements_total: info.stats.agreements_total, - agreements_extended: info.stats.agreements_extended, - agreements_not_extended: info.stats.agreements_not_extended, - agreements_burned: info.stats.agreements_burned, - challenges_received: info.stats.challenges_received, - challenges_failed: info.stats.challenges_failed, - max_capacity, - available_capacity, - }; - - results.push(MatchedProvider { - account: account.encode(), - info: provider_response, - match_score: score, - available_capacity, - partial_reason, - }); - } - - // Sort by score descending, then by price ascending for ties - results.sort_by(|a, b| { - b.match_score - .cmp(&a.match_score) - .then(a.info.price_per_byte.cmp(&b.info.price_per_byte)) - }); - - results.truncate(limit as usize); - results - } - - /// Get providers with sufficient capacity for the given bytes (paginated). - pub fn query_providers_with_capacity( - bytes_needed: u64, - offset: u32, - limit: u32, - ) -> Vec<(T::AccountId, crate::runtime_api::ProviderInfoResponse)> { - Providers::::iter() - .filter(|(_, info)| { - // Check accepting status - if !info.settings.accepting_primary - && info.settings.replica_sync_price.is_none() - { - return false; - } - - // Check capacity - let max_capacity = info.settings.max_capacity; - if max_capacity > 0 { - let available = max_capacity.saturating_sub(info.committed_bytes); - if available < bytes_needed { - return false; - } - } - - // Check stake (can they back the additional bytes?) - let new_committed = info.committed_bytes.saturating_add(bytes_needed); - let bytes_as_balance: BalanceOf = new_committed.saturated_into(); - if let Some(required_stake) = - T::MinStakePerByte::get().checked_mul(&bytes_as_balance) - { - return info.stake >= required_stake; - } - false - }) - .skip(offset as usize) - .take(limit as usize) - .map(|(account, info)| { - let max_capacity = info.settings.max_capacity; - let available_capacity = if max_capacity > 0 { - Some(max_capacity.saturating_sub(info.committed_bytes)) - } else { - None - }; - - ( - account, - crate::runtime_api::ProviderInfoResponse { - multiaddr: info.multiaddr.to_vec(), - public_key: info.public_key.to_vec(), - stake: info.stake.saturated_into::(), - committed_bytes: info.committed_bytes, - min_duration: info.settings.min_duration.saturated_into::(), - max_duration: info.settings.max_duration.saturated_into::(), - price_per_byte: info.settings.price_per_byte.saturated_into::(), - accepting_primary: info.settings.accepting_primary, - replica_sync_price: info - .settings - .replica_sync_price - .map(|p| p.saturated_into::()), - accepting_extensions: info.settings.accepting_extensions, - registered_at: info.stats.registered_at.saturated_into::(), - agreements_total: info.stats.agreements_total, - agreements_extended: info.stats.agreements_extended, - agreements_not_extended: info.stats.agreements_not_extended, - agreements_burned: info.stats.agreements_burned, - challenges_received: info.stats.challenges_received, - challenges_failed: info.stats.challenges_failed, - max_capacity, - available_capacity, - }, - ) - }) - .collect() - } - } } diff --git a/pallet/src/runtime_api.rs b/pallet/src/runtime_api.rs index 8ce8b36b..2636c328 100644 --- a/pallet/src/runtime_api.rs +++ b/pallet/src/runtime_api.rs @@ -165,22 +165,22 @@ sp_api::decl_runtime_apis! { fn agreement_info(bucket_id: BucketId, provider: AccountId) -> Option; /// Get all agreements for a bucket. - fn bucket_agreements(bucket_id: BucketId) -> Vec; + fn agreements_by_bucket_id(bucket_id: BucketId) -> Vec; /// Get all agreements for a provider. - fn provider_agreements(provider: AccountId) -> Vec; + fn agreements_by_provider(provider: AccountId) -> Vec; /// Get challenges expiring at a specific block. fn challenges_at(block: BlockNumber) -> Vec; /// Get all challenges for a specific bucket. - fn bucket_challenges(bucket_id: BucketId) -> Vec; + fn challenges_by_bucket(bucket_id: BucketId) -> Vec; /// Get all challenges targeting a specific provider. - fn provider_challenges(provider: AccountId) -> Vec; + fn challenges_by_provider(provider: AccountId) -> Vec; /// Get all challenges created by a specific challenger. - fn challenger_challenges(challenger: AccountId) -> Vec; + fn challenges_by_challenger(challenger: AccountId) -> Vec; /// Check if a provider has sufficient stake for additional bytes. fn can_accept_bytes(provider: AccountId, additional_bytes: u64) -> bool; diff --git a/runtimes/web3-storage-local/src/lib.rs b/runtimes/web3-storage-local/src/lib.rs index 28f9903e..ee6aa5ef 100644 --- a/runtimes/web3-storage-local/src/lib.rs +++ b/runtimes/web3-storage-local/src/lib.rs @@ -791,11 +791,11 @@ pallet_revive::impl_runtime_apis_plus_revive_traits!( StorageProvider::query_agreement_info(bucket_id, &provider) } - fn bucket_agreements(bucket_id: storage_primitives::BucketId) -> Vec { + fn agreements_by_bucket_id(bucket_id: storage_primitives::BucketId) -> Vec { StorageProvider::query_bucket_agreements(bucket_id) } - fn provider_agreements(provider: AccountId) -> Vec { + fn agreements_by_provider(provider: AccountId) -> Vec { StorageProvider::query_provider_agreements(&provider) } @@ -803,15 +803,15 @@ pallet_revive::impl_runtime_apis_plus_revive_traits!( StorageProvider::query_challenges_at(block) } - fn bucket_challenges(bucket_id: storage_primitives::BucketId) -> Vec { + fn challenges_by_bucket(bucket_id: storage_primitives::BucketId) -> Vec { StorageProvider::query_bucket_challenges(bucket_id) } - fn provider_challenges(provider: AccountId) -> Vec { + fn challenges_by_provider(provider: AccountId) -> Vec { StorageProvider::query_provider_challenges(&provider) } - fn challenger_challenges(challenger: AccountId) -> Vec { + fn challenges_by_challenger(challenger: AccountId) -> Vec { StorageProvider::query_challenger_challenges(&challenger) } diff --git a/runtimes/web3-storage-paseo/src/lib.rs b/runtimes/web3-storage-paseo/src/lib.rs index 308d4b68..05e02316 100644 --- a/runtimes/web3-storage-paseo/src/lib.rs +++ b/runtimes/web3-storage-paseo/src/lib.rs @@ -798,11 +798,11 @@ pallet_revive::impl_runtime_apis_plus_revive_traits!( StorageProvider::query_agreement_info(bucket_id, &provider) } - fn bucket_agreements(bucket_id: storage_primitives::BucketId) -> Vec { + fn agreements_by_bucket_id(bucket_id: storage_primitives::BucketId) -> Vec { StorageProvider::query_bucket_agreements(bucket_id) } - fn provider_agreements(provider: AccountId) -> Vec { + fn agreements_by_provider(provider: AccountId) -> Vec { StorageProvider::query_provider_agreements(&provider) } @@ -810,15 +810,15 @@ pallet_revive::impl_runtime_apis_plus_revive_traits!( StorageProvider::query_challenges_at(block) } - fn bucket_challenges(bucket_id: storage_primitives::BucketId) -> Vec { + fn challenges_by_bucket(bucket_id: storage_primitives::BucketId) -> Vec { StorageProvider::query_bucket_challenges(bucket_id) } - fn provider_challenges(provider: AccountId) -> Vec { + fn challenges_by_provider(provider: AccountId) -> Vec { StorageProvider::query_provider_challenges(&provider) } - fn challenger_challenges(challenger: AccountId) -> Vec { + fn challenges_by_challenger(challenger: AccountId) -> Vec { StorageProvider::query_challenger_challenges(&challenger) }