diff --git a/Cargo.lock b/Cargo.lock index 9644b859d..293b75ca5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10830,6 +10830,23 @@ dependencies = [ "scale-info", ] +[[package]] +name = "pallet-withdrawal-fix" +version = "7.3.4" +dependencies = [ + "ddc-primitives 7.3.2 (git+https://github.com/Cerebellum-Network/ddc-primitives.git?branch=staging)", + "frame-benchmarking", + "frame-support", + "frame-system", + "pallet-balances", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-xcm" version = "19.1.2" diff --git a/Cargo.toml b/Cargo.toml index 47c548615..c2d4c88a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "pallets/ddc-clusters-gov", "pallets/fee-handler", "pallets/origins", + "pallets/withdrawal-fix", "runtime/cere", "runtime/cere-dev", "contracts/customer-deposit", @@ -194,6 +195,7 @@ pallet-erc20 = { path = "pallets/erc20", default-features = false } pallet-erc721 = { path = "pallets/erc721", default-features = false } pallet-origins = { path = "pallets/origins", default-features = false } pallet-fee-handler = { path = "pallets/fee-handler", default-features = false } +pallet-withdrawal-fix = { path = "pallets/withdrawal-fix", default-features = false } # Cere External Dependencies ddc-primitives = { git = "https://github.com/Cerebellum-Network/ddc-primitives.git", branch = "staging", default-features = false } diff --git a/pallets/withdrawal-fix/Cargo.toml b/pallets/withdrawal-fix/Cargo.toml new file mode 100644 index 000000000..21a6fff68 --- /dev/null +++ b/pallets/withdrawal-fix/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "pallet-withdrawal-fix" +version.workspace = true +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +# 3rd-party dependencies +codec = { workspace = true } +scale-info = { workspace = true } + +# Substrate dependencies +frame-benchmarking = { workspace = true, optional = true } +frame-support = { workspace = true } +frame-system = { workspace = true } +pallet-balances = { workspace = true } +sp-core = { workspace = true } +sp-io = { workspace = true } +sp-runtime = { workspace = true } +sp-std = { workspace = true } + +# Cere dependencies +ddc-primitives = { workspace = true } + +[features] +default = ["std"] +std = [ + "codec/std", + "scale-info/std", + "sp-core/std", + "sp-runtime/std", + "sp-std/std", + "frame-support/std", + "frame-system/std", + "pallet-balances/std", + "sp-io/std", + "ddc-primitives/std", +] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "pallet-balances/runtime-benchmarks", +] +try-runtime = [] diff --git a/pallets/withdrawal-fix/src/lib.rs b/pallets/withdrawal-fix/src/lib.rs new file mode 100644 index 000000000..5e819fbe2 --- /dev/null +++ b/pallets/withdrawal-fix/src/lib.rs @@ -0,0 +1,191 @@ +//! # Withdrawal Fix Pallet +//! +//! This pallet is responsible for handling withdrawal fixes in a Substrate-based blockchain. +//! It provides functionality to fix withdrawal issues and manage withdrawal states. +//! +//! ## Overview +//! +//! - Allows governance to fix withdrawal issues. +//! - Manages withdrawal states and transitions. +//! - Emits events for key actions such as withdrawal fixes and state changes. +//! +//! ## Dispatchable Functions +//! +//! - `fix_withdrawal`: Allows governance to fix a withdrawal issue. +//! - `update_withdrawal_state`: Allows governance to update withdrawal state. + +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(feature = "runtime-benchmarks")] +pub mod benchmarking; +pub mod weights; + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +use frame_support::{ + traits::{ + fungible::{Inspect, Mutate}, + }, +}; +use codec::{Encode, Decode, DecodeWithMemTracking, MaxEncodedLen}; +use scale_info::TypeInfo; +pub use pallet::*; + +#[allow(deprecated)] +#[allow(clippy::let_unit_value)] +#[allow(clippy::manual_inspect)] +#[frame_support::pallet] +pub mod pallet { + use frame_support::pallet_prelude::*; + use frame_system::pallet_prelude::*; + use crate::weights::WeightInfo; + + use super::*; + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The overarching runtime event type. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + /// Native Currency Support. + type Currency: Mutate + Inspect; + /// Governance origin for privileged calls. + type GovernanceOrigin: EnsureOrigin; + /// Weight information for extrinsics in this pallet. + type WeightInfo: crate::weights::WeightInfo; + } + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// Withdrawal fix applied successfully. + WithdrawalFixed { account: T::AccountId, amount: u128 }, + /// Withdrawal state updated. + WithdrawalStateUpdated { account: T::AccountId, state: WithdrawalState }, + } + + #[pallet::error] + pub enum Error { + /// Withdrawal not found. + WithdrawalNotFound, + /// Invalid withdrawal state transition. + InvalidStateTransition, + /// Insufficient balance for withdrawal fix. + InsufficientBalance, + /// Withdrawal already processed. + WithdrawalAlreadyProcessed, + } + + #[pallet::storage] + #[pallet::getter(fn withdrawal_states)] + pub type WithdrawalStates = StorageMap< + _, + Blake2_128Concat, + T::AccountId, + WithdrawalState, + OptionQuery, + >; + + #[pallet::storage] + #[pallet::getter(fn pending_withdrawals)] + pub type PendingWithdrawals = StorageMap< + _, + Blake2_128Concat, + T::AccountId, + u128, + OptionQuery, + >; + + #[pallet::call] + impl Pallet { + /// Fix a withdrawal issue for a specific account. + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::fix_withdrawal())] + pub fn fix_withdrawal( + origin: OriginFor, + account: T::AccountId, + amount: u128, + ) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + + // Check if withdrawal exists + ensure!( + PendingWithdrawals::::contains_key(&account), + Error::::WithdrawalNotFound + ); + + // Check if withdrawal is already processed + let current_state = WithdrawalStates::::get(&account); + ensure!( + current_state != Some(WithdrawalState::Processed), + Error::::WithdrawalAlreadyProcessed + ); + + // Update withdrawal state to processed + WithdrawalStates::::insert(&account, WithdrawalState::Processed); + + // Remove from pending withdrawals + PendingWithdrawals::::remove(&account); + + Self::deposit_event(Event::WithdrawalFixed { account, amount }); + Ok(()) + } + + /// Update withdrawal state for a specific account. + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::update_withdrawal_state())] + pub fn update_withdrawal_state( + origin: OriginFor, + account: T::AccountId, + state: WithdrawalState, + ) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + + // Validate state transition + let current_state = WithdrawalStates::::get(&account); + Self::validate_state_transition(current_state, &state)?; + + // Update state + WithdrawalStates::::insert(&account, state.clone()); + + Self::deposit_event(Event::WithdrawalStateUpdated { account, state }); + Ok(()) + } + } + + impl Pallet { + /// Validate state transition + fn validate_state_transition( + current_state: Option, + new_state: &WithdrawalState, + ) -> Result<(), Error> { + match (current_state, new_state) { + (None, WithdrawalState::Pending) => Ok(()), + (Some(WithdrawalState::Pending), WithdrawalState::Processing) => Ok(()), + (Some(WithdrawalState::Processing), WithdrawalState::Processed) => Ok(()), + (Some(WithdrawalState::Processing), WithdrawalState::Failed) => Ok(()), + (Some(WithdrawalState::Failed), WithdrawalState::Pending) => Ok(()), + _ => Err(Error::::InvalidStateTransition), + } + } + } +} + +/// Withdrawal state enumeration +#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen)] +pub enum WithdrawalState { + /// Withdrawal is pending + Pending, + /// Withdrawal is being processed + Processing, + /// Withdrawal has been processed successfully + Processed, + /// Withdrawal failed + Failed, +} diff --git a/pallets/withdrawal-fix/src/mock.rs b/pallets/withdrawal-fix/src/mock.rs new file mode 100644 index 000000000..f9653f03c --- /dev/null +++ b/pallets/withdrawal-fix/src/mock.rs @@ -0,0 +1,116 @@ +//! Mock runtime for testing the withdrawal-fix pallet + +use crate as pallet_withdrawal_fix; +use frame_support::{ + construct_runtime, parameter_types, + traits::{ConstU32, Everything}, +}; +use sp_core::H256; +use sp_runtime::{ + traits::{BlakeTwo256, IdentityLookup}, + BuildStorage, +}; + +type Block = frame_system::mocking::MockBlock; + +construct_runtime!( + pub enum Test + { + System: frame_system, + Balances: pallet_balances, + WithdrawalFix: pallet_withdrawal_fix, + } +); + +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const SS58Prefix: u8 = 42; +} + +impl frame_system::Config for Test { + type BaseCallFilter = Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type Nonce = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Block = Block; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = BlockHashCount; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = SS58Prefix; + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; + type RuntimeTask = (); + type ExtensionsWeightInfo = (); + type SingleBlockMigrations = (); + type MultiBlockMigrator = (); + type PreInherents = (); + type PostInherents = (); + type PostTransactions = (); +} + +parameter_types! { + pub const ExistentialDeposit: u128 = 1; + pub const MaxLocks: u32 = 10; + pub const MaxReserves: u32 = 10; +} + +impl pallet_balances::Config for Test { + type MaxLocks = MaxLocks; + type MaxReserves = MaxReserves; + type ReserveIdentifier = [u8; 8]; + type Balance = u128; + type DustRemoval = (); + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type WeightInfo = (); + type FreezeIdentifier = (); + type MaxFreezes = (); + type RuntimeHoldReason = (); + type RuntimeFreezeReason = (); + type DoneSlashHandler = (); +} + +parameter_types! { + pub const MaxWithdrawals: u32 = 1000; +} + +impl pallet_withdrawal_fix::Config for Test { + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type GovernanceOrigin = frame_system::EnsureRoot; + type WeightInfo = pallet_withdrawal_fix::weights::SubstrateWeight; +} + +pub fn new_test_ext() -> sp_io::TestExternalities { + let mut storage = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap(); + + pallet_balances::GenesisConfig:: { + balances: vec![ + (1, 1000), + (2, 2000), + (3, 3000), + ], + dev_accounts: None, + } + .assimilate_storage(&mut storage) + .unwrap(); + + let mut ext = sp_io::TestExternalities::new(storage); + ext.execute_with(|| System::set_block_number(1)); + ext +} diff --git a/pallets/withdrawal-fix/src/tests.rs b/pallets/withdrawal-fix/src/tests.rs new file mode 100644 index 000000000..c4c25c620 --- /dev/null +++ b/pallets/withdrawal-fix/src/tests.rs @@ -0,0 +1,163 @@ +//! Tests for the withdrawal-fix pallet + +use super::*; +use crate::mock::*; +use frame_support::{assert_noop, assert_ok}; +use crate::{Error, WithdrawalState}; + +#[test] +fn test_fix_withdrawal_success() { + new_test_ext().execute_with(|| { + let account = 1; + let amount = 100; + + // Set up pending withdrawal + PendingWithdrawals::::insert(account, amount); + + // Fix withdrawal + assert_ok!(WithdrawalFix::fix_withdrawal( + RuntimeOrigin::root(), + account, + amount + )); + + // Check state + assert_eq!(WithdrawalStates::::get(account), Some(WithdrawalState::Processed)); + assert!(!PendingWithdrawals::::contains_key(account)); + + // Check event + System::assert_last_event( + Event::WithdrawalFixed { account, amount }.into() + ); + }); +} + +#[test] +fn test_fix_withdrawal_not_found() { + new_test_ext().execute_with(|| { + let account = 1; + let amount = 100; + + // Try to fix non-existent withdrawal + assert_noop!( + WithdrawalFix::fix_withdrawal(RuntimeOrigin::root(), account, amount), + Error::::WithdrawalNotFound + ); + }); +} + +#[test] +fn test_fix_withdrawal_already_processed() { + new_test_ext().execute_with(|| { + let account = 1; + let amount = 100; + + // Set up already processed withdrawal + WithdrawalStates::::insert(account, WithdrawalState::Processed); + // Also need to set up a pending withdrawal to pass the first check + PendingWithdrawals::::insert(account, amount); + + // Try to fix already processed withdrawal + assert_noop!( + WithdrawalFix::fix_withdrawal(RuntimeOrigin::root(), account, amount), + Error::::WithdrawalAlreadyProcessed + ); + }); +} + +#[test] +fn test_update_withdrawal_state_success() { + new_test_ext().execute_with(|| { + let account = 1; + let state = WithdrawalState::Pending; + + // Update state + assert_ok!(WithdrawalFix::update_withdrawal_state( + RuntimeOrigin::root(), + account, + state.clone() + )); + + // Check state + assert_eq!(WithdrawalStates::::get(account), Some(state.clone())); + + // Check event + System::assert_last_event( + Event::WithdrawalStateUpdated { account, state }.into() + ); + }); +} + +#[test] +fn test_update_withdrawal_state_invalid_transition() { + new_test_ext().execute_with(|| { + let account = 1; + + // Set initial state to Processed + WithdrawalStates::::insert(account, WithdrawalState::Processed); + + // Try invalid transition from Processed to Pending + assert_noop!( + WithdrawalFix::update_withdrawal_state( + RuntimeOrigin::root(), + account, + WithdrawalState::Pending + ), + Error::::InvalidStateTransition + ); + }); +} + +#[test] +fn test_valid_state_transitions() { + new_test_ext().execute_with(|| { + let account = 1; + + // Test valid transitions + let valid_transitions = vec![ + (None, WithdrawalState::Pending), + (Some(WithdrawalState::Pending), WithdrawalState::Processing), + (Some(WithdrawalState::Processing), WithdrawalState::Processed), + (Some(WithdrawalState::Processing), WithdrawalState::Failed), + (Some(WithdrawalState::Failed), WithdrawalState::Pending), + ]; + + for (from, to) in valid_transitions { + if let Some(state) = from { + WithdrawalStates::::insert(account, state); + } + + assert_ok!(WithdrawalFix::update_withdrawal_state( + RuntimeOrigin::root(), + account, + to.clone() + )); + + assert_eq!(WithdrawalStates::::get(account), Some(to)); + } + }); +} + +#[test] +fn test_unauthorized_access() { + new_test_ext().execute_with(|| { + let account = 1; + let amount = 100; + + // Test unauthorized fix_withdrawal + assert_noop!( + WithdrawalFix::fix_withdrawal(RuntimeOrigin::signed(account), account, amount), + sp_runtime::traits::BadOrigin + ); + + // Test unauthorized update_withdrawal_state + assert_noop!( + WithdrawalFix::update_withdrawal_state( + RuntimeOrigin::signed(account), + account, + WithdrawalState::Pending + ), + sp_runtime::traits::BadOrigin + ); + }); +} diff --git a/pallets/withdrawal-fix/src/weights.rs b/pallets/withdrawal-fix/src/weights.rs new file mode 100644 index 000000000..ffbde44b6 --- /dev/null +++ b/pallets/withdrawal-fix/src/weights.rs @@ -0,0 +1,30 @@ +//! Weights for the withdrawal-fix pallet + +#![cfg_attr(feature = "std", allow(unused_imports))] + +use frame_support::{traits::Get, weights::Weight}; + +/// Weight functions needed for pallet_withdrawal_fix. +pub trait WeightInfo { + /// Weight for `fix_withdrawal`. + fn fix_withdrawal() -> Weight; + /// Weight for `update_withdrawal_state`. + fn update_withdrawal_state() -> Weight; +} + +/// Weights for pallet_withdrawal_fix using the Substrate node and recommended hardware. +pub struct SubstrateWeight(sp_std::marker::PhantomData); + +impl WeightInfo for SubstrateWeight { + fn fix_withdrawal() -> Weight { + Weight::from_parts(50_000, 0) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(2)) + } + + fn update_withdrawal_state() -> Weight { + Weight::from_parts(30_000, 0) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } +} diff --git a/runtime/common/src/migrations/tests.rs b/runtime/common/src/migrations/tests.rs new file mode 100644 index 000000000..e01235e33 --- /dev/null +++ b/runtime/common/src/migrations/tests.rs @@ -0,0 +1,187 @@ +//! Tests for delegated staking fix migration +//! +//! These tests validate the migration logic for fixing the delegated staking +//! issue described in https://github.com/paritytech/polkadot-sdk/issues/9743 + +#[cfg(test)] +mod tests { + use super::super::delegated_staking_fix::*; + use frame_support::{ + migrations::SteppedMigration, + weights::WeightMeter, + traits::GetStorageVersion, + }; + use sp_runtime::traits::Zero; + + // Mock runtime for testing + #[derive(Clone, Debug, PartialEq, Eq)] + struct MockAccountId(u32); + + impl codec::Encode for MockAccountId { + fn encode(&self) -> Vec { + self.0.encode() + } + } + + impl codec::Decode for MockAccountId { + fn decode(input: &mut I) -> Result { + u32::decode(input).map(MockAccountId) + } + } + + impl codec::MaxEncodedLen for MockAccountId { + fn max_encoded_len() -> usize { + u32::max_encoded_len() + } + } + + // Mock runtime type for testing + struct MockRuntime; + + impl frame_system::Config for MockRuntime { + type AccountId = MockAccountId; + type Block = (); + type BlockHashCount = (); + type BlockLength = (); + type BlockWeights = (); + type DbWeight = (); + type Hash = (); + type Hashing = (); + type Header = (); + type Index = (); + type Lookup = (); + type MaxConsumers = (); + type OnKilledAccount = (); + type OnNewAccount = (); + type OnSetCode = (); + type PalletInfo = (); + type RuntimeCall = (); + type RuntimeEvent = (); + type RuntimeOrigin = (); + type SS58Prefix = (); + type SystemWeightInfo = (); + type Version = (); + } + + #[test] + fn test_migration_states() { + // Test that migration states can be created and encoded/decoded + let account = MockAccountId(1); + let state = MigrationState::ScanningPoolMembers(account.clone()); + + let encoded = state.encode(); + let decoded = MigrationState::::decode(&mut &encoded[..]).unwrap(); + + assert_eq!(state, decoded); + } + + #[test] + fn test_migration_id() { + // Test that migration ID is correctly formed + let id = DelegatedStakingFixMigration::::id(); + assert_eq!(id.pallet_id, *RUNTIME_MIGRATIONS_ID); + assert_eq!(id.version_from, 0); + assert_eq!(id.version_to, 1); + } + + #[test] + fn test_weight_requirements() { + // Test weight calculations for different states + let account = MockAccountId(1); + + let scanning_state = MigrationState::ScanningPoolMembers(account.clone()); + let fixing_state = MigrationState::FixingDelegatedStaking(account); + let verifying_state = MigrationState::VerifyingFix; + let finished_state = MigrationState::Finished; + + let scanning_weight = DelegatedStakingFixMigration::::required_weight(&scanning_state); + let fixing_weight = DelegatedStakingFixMigration::::required_weight(&fixing_state); + let verifying_weight = DelegatedStakingFixMigration::::required_weight(&verifying_state); + let finished_weight = DelegatedStakingFixMigration::::required_weight(&finished_state); + + // Scanning should be lighter than fixing + assert!(scanning_weight.ref_time() < fixing_weight.ref_time()); + + // Verifying should be moderate + assert!(verifying_weight.ref_time() < fixing_weight.ref_time()); + assert!(verifying_weight.ref_time() > scanning_weight.ref_time()); + + // Finished should be zero + assert!(finished_weight.is_zero()); + } + + #[test] + fn test_migration_state_transitions() { + // Test the logical flow of migration states + let start_state = DelegatedStakingFixMigration::::start_migration(); + + match start_state { + MigrationState::ScanningPoolMembers(_) => { + // Correct initial state + }, + _ => panic!("Migration should start with ScanningPoolMembers state"), + } + } + + #[test] + fn test_mature_unbonding_detection() { + // Test helper functions for detecting mature unbonding chunks + let account = MockAccountId(1); + let current_era = 1541u32; + + // Test the helper function (even though it's a template) + let has_mature = super::super::helpers::has_mature_unbonding_chunks::( + &account, + current_era + ); + + // Template implementation returns false, but in real implementation + // this would check actual unbonding chunks + assert_eq!(has_mature, false); + + let mature_amount = super::super::helpers::get_mature_unbonding_amount::( + &account, + current_era + ); + + // Template implementation returns 0, but in real implementation + // this would return the actual mature amount + assert_eq!(mature_amount, 0u128); + } + + #[test] + fn test_migration_completion() { + // Test that migration properly completes + let mut meter = WeightMeter::new(); + meter.set_remaining(Weight::from_parts(1_000_000_000, 1_000_000)); + + // Start with finished state to test completion + let cursor = Some(MigrationState::Finished); + + let result = DelegatedStakingFixMigration::::step(cursor, &mut meter); + + // Should return None when finished + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_insufficient_weight_handling() { + // Test that migration properly handles insufficient weight + let mut meter = WeightMeter::new(); + meter.set_remaining(Weight::from_parts(1_000, 100)); // Very low weight + + let cursor = None; // Start migration + + let result = DelegatedStakingFixMigration::::step(cursor, &mut meter); + + // Should return InsufficientWeight error + match result { + Err(frame_support::migrations::SteppedMigrationError::InsufficientWeight { required: _ }) => { + // Expected error + }, + _ => panic!("Should return InsufficientWeight error with low weight"), + } + } +} +