diff --git a/docs/design/scalable-web3-storage-implementation.md b/docs/design/scalable-web3-storage-implementation.md index d141c948..9bd68f28 100644 --- a/docs/design/scalable-web3-storage-implementation.md +++ b/docs/design/scalable-web3-storage-implementation.md @@ -905,9 +905,10 @@ sp_api::decl_runtime_apis! { fn provider_agreements(provider: AccountId) -> Vec; // ── Challenges ──────────────────────────────────────────────────── - /// Challenges expiring at a specific block. Used by provider nodes - /// and challengers to track outstanding challenges. fn challenges_at(block: BlockNumber) -> Vec; + fn bucket_challenges(bucket_id: BucketId) -> Vec; + fn provider_challenges(provider: AccountId) -> Vec; + fn challenger_challenges(challenger: AccountId) -> Vec; } } ``` diff --git a/packages/papi/.papi/descriptors/package.json b/packages/papi/.papi/descriptors/package.json index d4339b98..6ac6db66 100644 --- a/packages/papi/.papi/descriptors/package.json +++ b/packages/papi/.papi/descriptors/package.json @@ -1,5 +1,5 @@ { - "version": "0.1.0-autogenerated.10209081896432044669", + "version": "0.1.0-autogenerated.7748710851600088567", "name": "@polkadot-api/descriptors", "files": [ "dist" diff --git a/packages/papi/.papi/metadata/parachain.scale b/packages/papi/.papi/metadata/parachain.scale index ba1ce9f5..4decf8c7 100644 Binary files a/packages/papi/.papi/metadata/parachain.scale and b/packages/papi/.papi/metadata/parachain.scale differ diff --git a/packages/papi/.papi/polkadot-api.json b/packages/papi/.papi/polkadot-api.json index 79a6b127..3992b649 100644 --- a/packages/papi/.papi/polkadot-api.json +++ b/packages/papi/.papi/polkadot-api.json @@ -6,7 +6,7 @@ "wsUrl": "ws://127.0.0.1:2222", "metadata": ".papi/metadata/parachain.scale", "genesis": "0x4545454545454545454545454545454545454545454545454545454545454545", - "codeHash": "0x7f4840e782eb44dc822e6def5142638a1a0ffc189e01d3d0f2af6fb79c4725f4" + "codeHash": "0x9c204208893e648411a2cec41864e595d0496cf5dff6b0b2d605c3868115b975" } } } diff --git a/packages/papi/package.json b/packages/papi/package.json index 1b79ec11..92b645a6 100644 --- a/packages/papi/package.json +++ b/packages/papi/package.json @@ -11,7 +11,8 @@ }, "scripts": { "papi:install": "papi generate", - "papi:generate": "papi add -w ws://127.0.0.1:2222 parachain && papi generate" + "papi:generate": "papi add -w ws://127.0.0.1:2222 parachain && papi generate", + "papi:update": "papi update parachain" }, "devDependencies": { "@polkadot-api/cli": "^0.21.5" diff --git a/pallet/src/impls/agreements.rs b/pallet/src/impls/agreements.rs new file mode 100644 index 00000000..3d90f29d --- /dev/null +++ b/pallet/src/impls/agreements.rs @@ -0,0 +1,421 @@ +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(crate) fn validate_duration( + settings: &ProviderSettings, + duration: BlockNumberFor, + ) -> DispatchResult { + ensure!( + duration >= settings.min_duration, + Error::::DurationTooShort + ); + ensure!( + duration <= settings.max_duration, + Error::::DurationTooLong + ); + Ok(()) + } + + pub(crate) 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(crate) 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 { + // Capture the position before removal so the snapshot's + // positional signer bitfield can be re-indexed to match. + let pos = bucket.primary_providers.iter().position(|p| p == provider); + bucket.primary_providers.retain(|p| p != provider); + if let (Some(pos), Some(snapshot)) = (pos, bucket.snapshot.as_mut()) { + snapshot.remove_provider_bit(pos); + } + } + }); + + 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(crate) 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..55f01b01 --- /dev/null +++ b/pallet/src/impls/buckets.rs @@ -0,0 +1,193 @@ +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(); + + // Refuse to delete the bucket while any of its agreements has a + // pending challenge — otherwise tearing down here would let the + // provider escape a live slashable challenge. Checked before any + // state mutation/payout so the whole call is a no-op on failure. + for (provider, _) in &agreements { + ensure!( + PendingChallengesByBucket::::get(bucket_id, provider) == 0, + Error::::AgreementHasPendingChallenge + ); + } + + 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(crate) 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..5ace689f --- /dev/null +++ b/pallet/src/impls/challenges.rs @@ -0,0 +1,175 @@ +use crate::*; +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, SlashReason}; + +impl Pallet { + pub(crate) 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 { + // Deposit comes from `T::ChallengeDeposit` — a runtime constant + // sized to make spam expensive without pricing out legitimate + // challengers. Previously hardcoded `100u32` (1e-10 of a token + // at 12 decimals), which made challenge spam effectively free. + let deposit: BalanceOf = T::ChallengeDeposit::get(); + + 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, + }; + + // Cap the number of challenges that can share this deadline so the + // `on_finalize` sweep stays bounded (and within the weight + // `on_initialize` reserves for it). `NextChallengeIndex(deadline)` + // is the count ever allocated for that deadline and is never + // decremented, so it is a tight upper bound on the sweep size. + ensure!( + NextChallengeIndex::::get(deadline) < T::MaxChallengesPerDeadline::get(), + Error::::TooManyChallengesThisBlock + ); + + // Allocate a stable per-deadline index. Unlike the old + // `Vec`-position scheme, this counter is never decremented when a + // sibling challenge resolves, so the `ChallengeId` we emit stays + // valid for the life of the challenge. + let index = NextChallengeIndex::::mutate(deadline, |n| { + let i = *n; + *n = n.saturating_add(1); + i + }); + Challenges::::insert(deadline, index, &challenge); + + // Bump the pending-challenge counters. These are decremented + // exactly once per resolution (defended/invalid-response in + // `respond_to_challenge`, or timeout in `on_finalize`), so a + // fully-resolved provider/bucket returns to 0. They gate + // `complete_deregister` and agreement teardown so a provider can't + // escape a live challenge. + PendingChallenges::::mutate(&provider, |n| *n = n.saturating_add(1)); + PendingChallengesByBucket::::mutate(bucket_id, &provider, |n| *n = n.saturating_add(1)); + + // 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); + } + }); + + // Bump challenger's total_challenges aggregate so the SDK's + // `get_challenge_stats` doesn't have to scan event history. + ChallengerStats::::mutate(&challenger, |stats| { + stats.total_challenges = stats.total_challenges.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 for failing a challenge. + /// + /// This: + /// 1. Slashes the provider's entire stake + /// 2. Refunds the challenger's deposit plus a 10% slash reward + /// 3. Updates provider statistics + /// 4. Emits `ChallengeSlashed` with the supplied `SlashReason` + /// + /// `reason` distinguishes a timeout (`on_finalize` path) from an + /// invalid response (`respond_to_challenge` paths). Both lead to the + /// same financial outcome — the distinction is for observers + /// reading the event log. + pub(crate) fn slash_provider_for_failed_challenge( + challenge: &Challenge, + challenge_id: ChallengeId>, + reason: SlashReason, + ) { + // 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; + + // Slash the provider's stake, capturing the imbalance so we can + // settle it into the Treasury instead of burning it. + let (slashed_imbalance, remaining) = + T::Currency::slash_reserved(&challenge.provider, slashed_amount); + let actually_slashed = slashed_amount.saturating_sub(remaining); + + // Per the design, a successful challenger receives NO reward — + // only their deposit back. Refund the deposit and route the + // entire slashed amount to the Treasury. Paying the challenger + // a cut of the slash would create a profit-from-slashing + // incentive (the "refund me or I burn" blackmail channel the + // design explicitly closes). `resolve_creating` restores the + // issuance burned by `slash_reserved`, keeping issuance whole. + T::Currency::unreserve(&challenge.challenger, challenge.deposit); + T::Currency::resolve_creating(&T::Treasury::get(), slashed_imbalance); + + // 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); + + // Bump the challenger's successful-challenge count. Challengers + // earn no reward (the slashed stake goes entirely to the + // Treasury), so only the counter moves here. + ChallengerStats::::mutate(&challenge.challenger, |stats| { + stats.successful_challenges = stats.successful_challenges.saturating_add(1); + }); + + // Emit event + Self::deposit_event(Event::ChallengeSlashed { + challenge_id, + provider: challenge.provider.clone(), + slashed_amount: actually_slashed, + challenger_reward: Zero::zero(), + reason, + }); + } + } + + /// Decrement both pending-challenge counters for a resolved + /// `(bucket, provider)` challenge. Called from the two resolution + /// sites — `respond_to_challenge` (after the `take` consumes the + /// challenge, covering both the defended and invalid-response paths) + /// and `on_finalize` (per drained timed-out challenge) — never from + /// `slash_provider_for_failed_challenge`, which both sites share and + /// which would otherwise double-count. `saturating_sub` keeps the + /// counters non-negative even if invariants are ever violated. + pub(crate) fn decrement_pending(bucket_id: BucketId, provider: &T::AccountId) { + PendingChallenges::::mutate(provider, |n| *n = n.saturating_sub(1)); + PendingChallengesByBucket::::mutate(bucket_id, provider, |n| *n = n.saturating_sub(1)); + } +} diff --git a/pallet/src/impls/checkpoints.rs b/pallet/src/impls/checkpoints.rs new file mode 100644 index 00000000..8669c537 --- /dev/null +++ b/pallet/src/impls/checkpoints.rs @@ -0,0 +1,114 @@ +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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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..663ac209 --- /dev/null +++ b/pallet/src/impls/marketplace.rs @@ -0,0 +1,201 @@ +use crate::*; +use alloc::vec::Vec; +use frame_support::pallet_prelude::*; +use sp_runtime::traits::{CheckedMul, SaturatedConversion}; + +impl Pallet { + /// 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..131b67fa --- /dev/null +++ b/pallet/src/impls/members.rs @@ -0,0 +1,140 @@ +use crate::Member; +use crate::*; +use frame_support::pallet_prelude::*; +use storage_primitives::{BucketId, Role}; + +impl Pallet { + pub(crate) 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(crate) 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(crate) 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..91bdabc3 --- /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(crate) 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(crate) 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(()) + } +} diff --git a/pallet/src/impls/queries.rs b/pallet/src/impls/queries.rs new file mode 100644 index 00000000..fda9651f --- /dev/null +++ b/pallet/src/impls/queries.rs @@ -0,0 +1,290 @@ +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, ReplicaSyncRecord}; + +fn challenge_to_response( + deadline: BlockNumberFor, + index: u16, + 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, + deposit: c.deposit.saturated_into::(), + } +} + +fn agreement_to_response( + bucket_id: BucketId, + provider: &T::AccountId, + agreement: StorageAgreement, +) -> crate::runtime_api::AgreementResponse { + 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(|r| ReplicaSyncRecord { + mmr_root: r.mmr_root, + start_seq: r.start_seq, + leaf_count: r.leaf_count, + block: r.block.saturated_into::(), + }), + }, + }, + started_at: agreement.started_at.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(), + commitment_nonce: s.commitment_nonce, + }), + 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| agreement_to_response::(bucket_id, provider, agreement)) + } + + /// Query all agreements for a bucket. + pub fn query_bucket_agreements( + bucket_id: BucketId, + ) -> Vec { + StorageAgreements::::iter_prefix(bucket_id) + .map(|(provider, agreement)| { + agreement_to_response::(bucket_id, &provider, agreement) + }) + .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)| { + agreement_to_response::(bucket_id, provider, agreement) + }) + .collect() + } + + /// Query challenges expiring at a specific block. + pub fn query_challenges_at( + block: BlockNumberFor, + ) -> Vec { + Challenges::::iter_prefix(block) + .map(|(index, challenge)| challenge_to_response::(block, index, challenge)) + .collect() + } + + /// Query all challenges for a specific bucket. + pub fn query_bucket_challenges( + bucket_id: BucketId, + ) -> Vec { + Challenges::::iter() + .filter(|(_, _, c)| c.bucket_id == bucket_id) + .map(|(deadline, index, c)| challenge_to_response::(deadline, index, c)) + .collect() + } + + /// Query all challenges targeting a specific provider. + pub fn query_provider_challenges( + provider: &T::AccountId, + ) -> Vec { + Challenges::::iter() + .filter(|(_, _, c)| &c.provider == provider) + .map(|(deadline, index, c)| challenge_to_response::(deadline, index, c)) + .collect() + } + + /// Query all challenges created by a specific challenger. + pub fn query_challenger_challenges( + challenger: &T::AccountId, + ) -> Vec { + Challenges::::iter() + .filter(|(_, _, c)| &c.challenger == challenger) + .map(|(deadline, index, c)| challenge_to_response::(deadline, index, 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..293bd0d6 --- /dev/null +++ b/pallet/src/impls/signatures.rs @@ -0,0 +1,118 @@ +use crate::*; +use frame_support::pallet_prelude::*; +use sp_runtime::traits::SaturatedConversion; + +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. + /// Reject a `CommitmentPayload::nonce` that is too far behind (or ahead + /// of) the current block. This prevents an attacker who captures one + /// signed commitment from replaying it forever. + pub(crate) fn ensure_recent_nonce(nonce: u64) -> DispatchResult { + let current: u64 = frame_system::Pallet::::block_number().saturated_into(); + let max_age: u64 = T::MaxNonceAge::get().saturated_into(); + // Future-dated nonces are nonsensical — the signer can only know + // the current block at sign-time. Allow exact equality. + ensure!(nonce <= current, Error::::CommitmentNonceTooOld); + ensure!( + current.saturating_sub(nonce) <= max_age, + Error::::CommitmentNonceTooOld + ); + Ok(()) + } + + /// 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(crate) 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(crate) 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 7fe2e222..efcee839 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; @@ -52,11 +53,11 @@ 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, ChallengerStatRecord, CommitmentPayload, EndAction, - MerkleProof, MmrProof, ProviderRole, RemovalReason, ReplayError, ReplayWindow, - ReplicaSyncRecord, Role, SlashReason, HISTORICAL_ROOT_PRIMES, + MerkleProof, MmrProof, ProviderRole, RemovalReason, ReplayWindow, ReplicaSyncRecord, Role, + SlashReason, }; pub type BalanceOf = @@ -1605,7 +1606,7 @@ pub mod pallet { }); // Remove from bucket's primary providers if primary - // TODO(no-admin-left) + // TODO(no-primary-provider-left) if matches!(agreement.role, ProviderRole::Primary) { Buckets::::mutate(bucket_id, |maybe_bucket| { if let Some(bucket) = maybe_bucket { @@ -3092,1758 +3093,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. - /// Reject a `CommitmentPayload::nonce` that is too far behind (or ahead - /// of) the current block. This prevents an attacker who captures one - /// signed commitment from replaying it forever. - fn ensure_recent_nonce(nonce: u64) -> DispatchResult { - use sp_runtime::traits::SaturatedConversion; - let current: u64 = frame_system::Pallet::::block_number().saturated_into(); - let max_age: u64 = T::MaxNonceAge::get().saturated_into(); - // Future-dated nonces are nonsensical — the signer can only know - // the current block at sign-time. Allow exact equality. - ensure!(nonce <= current, Error::::CommitmentNonceTooOld); - ensure!( - current.saturating_sub(nonce) <= max_age, - Error::::CommitmentNonceTooOld - ); - Ok(()) - } - - 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 { - // Capture the position before removal so the snapshot's - // positional signer bitfield can be re-indexed to match. - let pos = bucket.primary_providers.iter().position(|p| p == provider); - bucket.primary_providers.retain(|p| p != provider); - if let (Some(pos), Some(snapshot)) = (pos, bucket.snapshot.as_mut()) { - snapshot.remove_provider_bit(pos); - } - } - }); - - 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(); - - // Refuse to delete the bucket while any of its agreements has a - // pending challenge — otherwise tearing down here would let the - // provider escape a live slashable challenge. Checked before any - // state mutation/payout so the whole call is a no-op on failure. - for (provider, _) in &agreements { - ensure!( - PendingChallengesByBucket::::get(bucket_id, provider) == 0, - Error::::AgreementHasPendingChallenge - ); - } - - 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 { - // Deposit comes from `T::ChallengeDeposit` — a runtime constant - // sized to make spam expensive without pricing out legitimate - // challengers. Previously hardcoded `100u32` (1e-10 of a token - // at 12 decimals), which made challenge spam effectively free. - let deposit: BalanceOf = T::ChallengeDeposit::get(); - - 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, - }; - - // Cap the number of challenges that can share this deadline so the - // `on_finalize` sweep stays bounded (and within the weight - // `on_initialize` reserves for it). `NextChallengeIndex(deadline)` - // is the count ever allocated for that deadline and is never - // decremented, so it is a tight upper bound on the sweep size. - ensure!( - NextChallengeIndex::::get(deadline) < T::MaxChallengesPerDeadline::get(), - Error::::TooManyChallengesThisBlock - ); - - // Allocate a stable per-deadline index. Unlike the old - // `Vec`-position scheme, this counter is never decremented when a - // sibling challenge resolves, so the `ChallengeId` we emit stays - // valid for the life of the challenge. - let index = NextChallengeIndex::::mutate(deadline, |n| { - let i = *n; - *n = n.saturating_add(1); - i - }); - Challenges::::insert(deadline, index, &challenge); - - // Bump the pending-challenge counters. These are decremented - // exactly once per resolution (defended/invalid-response in - // `respond_to_challenge`, or timeout in `on_finalize`), so a - // fully-resolved provider/bucket returns to 0. They gate - // `complete_deregister` and agreement teardown so a provider can't - // escape a live challenge. - PendingChallenges::::mutate(&provider, |n| *n = n.saturating_add(1)); - PendingChallengesByBucket::::mutate(bucket_id, &provider, |n| { - *n = n.saturating_add(1) - }); - - // 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); - } - }); - - // Bump challenger's total_challenges aggregate so the SDK's - // `get_challenge_stats` doesn't have to scan event history. - ChallengerStats::::mutate(&challenger, |stats| { - stats.total_challenges = stats.total_challenges.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()) - } - - /// Decrement both pending-challenge counters for a resolved - /// `(bucket, provider)` challenge. Called from the two resolution - /// sites — `respond_to_challenge` (after the `take` consumes the - /// challenge, covering both the defended and invalid-response paths) - /// and `on_finalize` (per drained timed-out challenge) — never from - /// `slash_provider_for_failed_challenge`, which both sites share and - /// which would otherwise double-count. `saturating_sub` keeps the - /// counters non-negative even if invariants are ever violated. - fn decrement_pending(bucket_id: BucketId, provider: &T::AccountId) { - PendingChallenges::::mutate(provider, |n| *n = n.saturating_sub(1)); - PendingChallengesByBucket::::mutate(bucket_id, provider, |n| { - *n = n.saturating_sub(1) - }); - } - - /// Slash a provider for failing a challenge. - /// - /// This: - /// 1. Slashes the provider's entire stake - /// 2. Refunds the challenger's deposit plus a 10% slash reward - /// 3. Updates provider statistics - /// 4. Emits `ChallengeSlashed` with the supplied `SlashReason` - /// - /// `reason` distinguishes a timeout (`on_finalize` path) from an - /// invalid response (`respond_to_challenge` paths). Both lead to the - /// same financial outcome — the distinction is for observers - /// reading the event log. - fn slash_provider_for_failed_challenge( - challenge: &Challenge, - challenge_id: ChallengeId>, - reason: SlashReason, - ) { - // 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; - - // Slash the provider's stake, capturing the imbalance so we can - // settle it into the Treasury instead of burning it. - let (slashed_imbalance, remaining) = - T::Currency::slash_reserved(&challenge.provider, slashed_amount); - let actually_slashed = slashed_amount.saturating_sub(remaining); - - // Per the design, a successful challenger receives NO reward — - // only their deposit back. Refund the deposit and route the - // entire slashed amount to the Treasury. Paying the challenger - // a cut of the slash would create a profit-from-slashing - // incentive (the "refund me or I burn" blackmail channel the - // design explicitly closes). `resolve_creating` restores the - // issuance burned by `slash_reserved`, keeping issuance whole. - T::Currency::unreserve(&challenge.challenger, challenge.deposit); - T::Currency::resolve_creating(&T::Treasury::get(), slashed_imbalance); - - // 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); - - // Bump the challenger's successful-challenge count. Challengers - // earn no reward (the slashed stake goes entirely to the - // Treasury), so only the counter moves here. - ChallengerStats::::mutate(&challenge.challenger, |stats| { - stats.successful_challenges = stats.successful_challenges.saturating_add(1); - }); - - // Emit event - Self::deposit_event(Event::ChallengeSlashed { - challenge_id, - provider: challenge.provider.clone(), - slashed_amount: actually_slashed, - challenger_reward: Zero::zero(), - reason, - }); - } - } - - // ───────────────────────────────────────────────────────────────────────── - // 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(), - commitment_nonce: s.commitment_nonce, - }), - 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 { - 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(|r| ReplicaSyncRecord { - mmr_root: r.mmr_root, - start_seq: r.start_seq, - leaf_count: r.leaf_count, - block: r.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 { - 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(|r| ReplicaSyncRecord { - mmr_root: r.mmr_root, - start_seq: r.start_seq, - leaf_count: r.leaf_count, - block: r.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 { - 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(|r| ReplicaSyncRecord { - mmr_root: r.mmr_root, - start_seq: r.start_seq, - leaf_count: r.leaf_count, - block: r.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::::iter_prefix(block) - .map(|(index, 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, - deposit: challenge.deposit.saturated_into::(), - }) - .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 95e661ef..459babab 100644 --- a/pallet/src/runtime_api.rs +++ b/pallet/src/runtime_api.rs @@ -109,6 +109,7 @@ pub struct BucketResponse { #[derive(Clone, PartialEq, Eq, Encode, Decode, TypeInfo, Debug)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub struct AgreementResponse { + pub bucket_id: BucketId, pub owner: Vec, // Encoded AccountId pub provider: Vec, // Encoded AccountId pub max_bytes: u64, @@ -172,6 +173,15 @@ sp_api::decl_runtime_apis! { /// 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; + + /// Get all challenges targeting a specific provider. + fn provider_challenges(provider: AccountId) -> Vec; + + /// Get all challenges created by a specific challenger. + fn challenger_challenges(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/pallet/src/tests/runtime_api.rs b/pallet/src/tests/runtime_api.rs index eb0a024b..01a058d1 100644 --- a/pallet/src/tests/runtime_api.rs +++ b/pallet/src/tests/runtime_api.rs @@ -100,6 +100,56 @@ fn query_agreement_info_none_for_unknown() { }); } +#[test] +fn query_bucket_agreements_returns_data() { + new_test_ext().execute_with(|| { + register_provider(2, 200); + register_provider(4, 200); + let bucket_id = setup_agreement(2, 1, 50, 200); + add_primary_to_bucket(4, 1, bucket_id, 30); + + let agreements = StorageProvider::query_bucket_agreements(bucket_id); + assert_eq!(agreements.len(), 2); + assert!(agreements.iter().all(|a| a.bucket_id == bucket_id)); + assert!(agreements.iter().any(|a| a.provider == 2u64.encode())); + assert!(agreements.iter().any(|a| a.provider == 4u64.encode())); + }); +} + +#[test] +fn query_bucket_agreements_empty_for_unknown() { + new_test_ext().execute_with(|| { + let agreements = StorageProvider::query_bucket_agreements(999); + assert!(agreements.is_empty()); + }); +} + +#[test] +fn query_provider_agreements_returns_data() { + new_test_ext().execute_with(|| { + register_provider(2, 200); + let bucket_a = setup_agreement(2, 1, 50, 200); + let bucket_b = setup_agreement(2, 3, 50, 200); + + let agreements = StorageProvider::query_provider_agreements(&2); + assert_eq!(agreements.len(), 2); + assert!(agreements.iter().all(|a| a.provider == 2u64.encode())); + assert!(agreements.iter().any(|a| a.bucket_id == bucket_a)); + assert!(agreements.iter().any(|a| a.bucket_id == bucket_b)); + }); +} + +#[test] +fn query_provider_agreements_empty_for_unknown() { + new_test_ext().execute_with(|| { + register_provider(2, 200); + setup_agreement(2, 1, 50, 200); + + let agreements = StorageProvider::query_provider_agreements(&99); + assert!(agreements.is_empty()); + }); +} + #[test] fn can_accept_bytes_checks_capacity() { new_test_ext().execute_with(|| { @@ -338,3 +388,113 @@ fn query_challenges_at_returns_data() { assert_eq!(remaining[0].provider, 4u64.encode()); }); } + +/// Helper: set up a bucket with two primaries (2 and 4), a snapshot signed by +/// both, and one open challenge per primary (challenger 3 -> provider 2, +/// challenger 5 -> provider 4). Returns the bucket_id. +fn setup_two_challenges() -> u64 { + frame_system::Pallet::::set_block_number(1); + register_provider(2, 200); + register_provider(4, 200); + let bucket_id = setup_agreement(2, 1, 50, 200); + add_primary_to_bucket(4, 1, bucket_id, 50); + + Buckets::::mutate(bucket_id, |maybe_bucket| { + if let Some(bucket) = maybe_bucket { + bucket.snapshot = Some(BucketSnapshot { + mmr_root: H256::repeat_byte(0xAB), + start_seq: 0, + leaf_count: 10, + checkpoint_block: 1, + primary_signers: vec![0x03], + commitment_nonce: 0, + }); + } + }); + + assert_ok!(StorageProvider::challenge_checkpoint( + RuntimeOrigin::signed(3), + bucket_id, + 2, + 0, + 0, + )); + assert_ok!(StorageProvider::challenge_checkpoint( + RuntimeOrigin::signed(5), + bucket_id, + 4, + 0, + 0, + )); + + bucket_id +} + +#[test] +fn query_bucket_challenges_returns_data() { + new_test_ext().execute_with(|| { + let bucket_id = setup_two_challenges(); + + let challenges = StorageProvider::query_bucket_challenges(bucket_id); + assert_eq!(challenges.len(), 2); + assert!(challenges.iter().all(|c| c.bucket_id == bucket_id)); + assert!(challenges.iter().any(|c| c.provider == 2u64.encode())); + assert!(challenges.iter().any(|c| c.provider == 4u64.encode())); + }); +} + +#[test] +fn query_bucket_challenges_empty_for_unknown() { + new_test_ext().execute_with(|| { + setup_two_challenges(); + + let challenges = StorageProvider::query_bucket_challenges(999); + assert!(challenges.is_empty()); + }); +} + +#[test] +fn query_provider_challenges_returns_data() { + new_test_ext().execute_with(|| { + let bucket_id = setup_two_challenges(); + + let challenges = StorageProvider::query_provider_challenges(&2); + assert_eq!(challenges.len(), 1); + assert_eq!(challenges[0].bucket_id, bucket_id); + assert_eq!(challenges[0].provider, 2u64.encode()); + assert_eq!(challenges[0].challenger, 3u64.encode()); + }); +} + +#[test] +fn query_provider_challenges_empty_for_unknown() { + new_test_ext().execute_with(|| { + setup_two_challenges(); + + let challenges = StorageProvider::query_provider_challenges(&99); + assert!(challenges.is_empty()); + }); +} + +#[test] +fn query_challenger_challenges_returns_data() { + new_test_ext().execute_with(|| { + let bucket_id = setup_two_challenges(); + + let challenges = StorageProvider::query_challenger_challenges(&5); + assert_eq!(challenges.len(), 1); + assert_eq!(challenges[0].bucket_id, bucket_id); + assert_eq!(challenges[0].provider, 4u64.encode()); + assert_eq!(challenges[0].challenger, 5u64.encode()); + }); +} + +#[test] +fn query_challenger_challenges_empty_for_unknown() { + new_test_ext().execute_with(|| { + setup_two_challenges(); + + let challenges = StorageProvider::query_challenger_challenges(&99); + assert!(challenges.is_empty()); + }); +} diff --git a/runtimes/web3-storage-local/src/lib.rs b/runtimes/web3-storage-local/src/lib.rs index ec68384b..a8542c3f 100644 --- a/runtimes/web3-storage-local/src/lib.rs +++ b/runtimes/web3-storage-local/src/lib.rs @@ -803,6 +803,18 @@ pallet_revive::impl_runtime_apis_plus_revive_traits!( StorageProvider::query_challenges_at(block) } + fn bucket_challenges(bucket_id: storage_primitives::BucketId) -> Vec { + StorageProvider::query_bucket_challenges(bucket_id) + } + + fn provider_challenges(provider: AccountId) -> Vec { + StorageProvider::query_provider_challenges(&provider) + } + + fn challenger_challenges(challenger: AccountId) -> Vec { + StorageProvider::query_challenger_challenges(&challenger) + } + fn can_accept_bytes(provider: AccountId, additional_bytes: u64) -> bool { StorageProvider::query_can_accept_bytes(&provider, additional_bytes) } diff --git a/runtimes/web3-storage-paseo/src/lib.rs b/runtimes/web3-storage-paseo/src/lib.rs index 9a538fc4..6529b984 100644 --- a/runtimes/web3-storage-paseo/src/lib.rs +++ b/runtimes/web3-storage-paseo/src/lib.rs @@ -812,6 +812,18 @@ pallet_revive::impl_runtime_apis_plus_revive_traits!( StorageProvider::query_challenges_at(block) } + fn bucket_challenges(bucket_id: storage_primitives::BucketId) -> Vec { + StorageProvider::query_bucket_challenges(bucket_id) + } + + fn provider_challenges(provider: AccountId) -> Vec { + StorageProvider::query_provider_challenges(&provider) + } + + fn challenger_challenges(challenger: AccountId) -> Vec { + StorageProvider::query_challenger_challenges(&challenger) + } + fn can_accept_bytes(provider: AccountId, additional_bytes: u64) -> bool { StorageProvider::query_can_accept_bytes(&provider, additional_bytes) }