//! # NPoS — pallet_staking config (FIX-6 Stage 2b, DC ORIGIN_CHAIN_NPOS_V1) //! //! **Infra DORMIDA** (David ratificó NPoS-5 2026-06-12): toda la maquinaria de //! staking cableada y lista, pero el set de validadores arranca FIJO //! (`invulnerables` + `ForceEra::ForceNone`) — la cadena corre PoA-equivalente //! hasta que se cumplan los 5 criterios empíricos de Phase 2 (CHN-1). La //! activación de la elección-por-stake = un governance flag (`ForceNew`). //! //! La **emisión** migra a [`OriginEraPayout`] (por-era, preserva la tasa lineal //! de FIX-6 Stage 1: `BlockReward` por bloque) con el `RewardRemainder` //! ([`DealWithEraRemainder`]) ruteando treasury 20% + subsidy 10% — esto //! mantiene el pool `og/subsy` alimentado (que el `OriginFeeAdapter` de Stage 2a //! consume; sin él se rompería el subsidio anti-clasista #16). //! //! Versiones verificadas (stable2509-3): pallet-staking 43.0.0 + //! frame-election-provider-support 43.0.0 + sp-staking 41.0.0. use frame_election_provider_support::onchain; use frame_election_provider_support::{ bounds::{ElectionBounds, ElectionBoundsBuilder}, SequentialPhragmen, }; use frame_support::parameter_types; use frame_support::traits::fungible::{Balanced, Credit}; use frame_support::traits::tokens::imbalance::{Imbalance, OnUnbalanced}; use frame_support::traits::{ConstBool, ConstU32, Nothing}; use frame_system::EnsureRoot; use sp_runtime::traits::AccountIdConversion; use sp_runtime::Perbill; use crate::configs::{ EmissionBlockReward, EmissionSubsidyPalletId, GovernanceTreasuryPalletId, MILLISECS_PER_BLOCK, }; use crate::{Balance, Balances, ChainAccountId, Runtime, RuntimeEvent, RuntimeHoldReason, Timestamp}; /// Crédito fungible de Balances (el tipo de imbalance que staking entrega a los /// handlers `RewardRemainder`/`Slash` en stable2509 — API hold-based, NO el /// `NegativeImbalance` clásico). type CreditOf = Credit; // ─── EraPayout custom: emisión lineal (preserva FIX-6 Stage 1) ─────── /// Emisión por-era que preserva EXACTAMENTE la tasa de FIX-6 Stage 1: /// `BlockReward` (10 OGC) por bloque, repartido 70% a los validadores /// (`validator_payout`) y 30% al `RewardRemainder` (`rest` → treasury+subsidy). /// Lineal-absoluta (fija por tiempo, no curva ideal-staking-rate de Polkadot): /// `total_era = BlockReward × (era_duration_millis / MILLISECS_PER_BLOCK)`. /// Ignora `total_staked`/`total_issuance` a propósito (la disinflación natural /// viene de que el reward fijo pesa menos sobre un issuance creciente). pub struct OriginEraPayout; impl pallet_staking::EraPayout for OriginEraPayout { fn era_payout( _total_staked: Balance, _total_issuance: Balance, era_duration_millis: u64, ) -> (Balance, Balance) { let blocks = (era_duration_millis / MILLISECS_PER_BLOCK) as u128; let total = EmissionBlockReward::get().saturating_mul(blocks); // 70% a validadores (AuthorShareBps = 7_000), 30% al remainder. let validator_payout = total.saturating_mul(7_000) / 10_000; let rest = total.saturating_sub(validator_payout); (validator_payout, rest) } } // ─── RewardRemainder: split treasury (20%) + subsidy (10%) ─────────── parameter_types! { /// Cuenta-tesorería (og/gvtry) — la MISMA de governance/emission. pub TreasuryAccount: ChainAccountId = GovernanceTreasuryPalletId::get().into_account_truncating(); /// Cuenta del subsidy pool (og/subsy) — alimenta el subsidio #16 que el /// OriginFeeAdapter de Stage 2a consume. pub SubsidyAccount: ChainAccountId = EmissionSubsidyPalletId::get().into_account_truncating(); } /// Handler del `rest` de cada era (30% del total emitido): lo reparte 2/3 /// treasury + 1/3 subsidy (= 20% + 10% del total de emisión, replicando el /// split per-block de FIX-6 Stage 1). **Crítico #16**: mantiene `og/subsy` /// alimentado bajo NPoS — sin esto el adapter de fees degradaría a /// `pool_covered=0` y el usuario pagaría el 100%. pub struct DealWithEraRemainder; /// Reparte el `rest` de una era (30% de la emisión) en `(treasury, subsidy)` = /// 2/3 + 1/3 = 20% + 10% del total. El residual (dust del redondeo entero) va al /// subsidy — favorece el pool anti-clasista #16, cero pérdida. Función pura para /// testear la invariante constitucional sin montar un mock runtime. pub(crate) fn era_remainder_split(rest: u128) -> (u128, u128) { let treasury = rest.saturating_mul(2) / 3; let subsidy = rest.saturating_sub(treasury); // residual al subsidy (treasury, subsidy) } impl OnUnbalanced for DealWithEraRemainder { fn on_nonzero_unbalanced(amount: CreditOf) { let (treasury_part, _) = era_remainder_split(amount.peek()); let (to_treasury, to_subsidy) = amount.split(treasury_part); // resolve deposita cada crédito en su cuenta vault; si falla, el crédito // se descarta (drop = burn) — nunca se duplica ni se pierde a un tercero. let _ = Balances::resolve(&TreasuryAccount::get(), to_treasury); let _ = Balances::resolve(&SubsidyAccount::get(), to_subsidy); } } // ─── onchain ElectionProvider (dormido: ForceNone no dispara elección) ─ /// ZST de config para `OnChainExecution`. Con `ForceEra::ForceNone` la elección /// casi nunca corre; el provider existe porque el Config lo exige. El genérico /// de `OnChainExecution` es ESTE struct (NO el Runtime): `System = Runtime`, /// `DataProvider = Staking`. pub struct OnChainSeqPhragmen; parameter_types! { /// Bounds de la elección on-chain. Con ForceNone es defensivo; acota voters/ /// targets para que `elect` nunca panic-falle por tamaño. pub OnChainElectionBounds: ElectionBounds = ElectionBoundsBuilder::default().build(); } impl onchain::Config for OnChainSeqPhragmen { type Sort = ConstBool; type System = Runtime; type Solver = SequentialPhragmen; type MaxBackersPerWinner = ConstU32<512>; // Debe ser >= MaxValidatorSet (150) — reusa el mismo techo. type MaxWinnersPerPage = MaxValidatorSet; type DataProvider = crate::Staking; type WeightInfo = (); type Bounds = OnChainElectionBounds; } // ─── Benchmarking config (obligatorio en no-std) ───────────────────── /// `BenchmarkingConfig` propio (el `TestBenchmarkingConfig` de pallet_staking /// solo existe bajo `std`; un runtime wasm lo exige siempre). pub struct OriginStakingBenchmarkingConfig; impl pallet_staking::BenchmarkingConfig for OriginStakingBenchmarkingConfig { type MaxValidators = ConstU32<150>; type MaxNominators = ConstU32<1_000>; } // ─── pallet_staking::Config ────────────────────────────────────────── parameter_types! { /// NPoS-6 (David ratificó): techo de 150 validadores activos (CHN-1). pub const MaxValidatorSet: u32 = 150; /// NPoS-8: 6 sessions × 4h = era de 24h. pub const SessionsPerEra: u32 = 6; /// NPoS-8: bonding de 7 eras = 7 días (PILAR_4 §6.1). pub const BondingDuration: u32 = 7; /// SlashDeferDuration < BondingDuration. 0 en dormido (sin slashes; los /// invulnerables no se slashan). Se calibra al activar NPoS (NPoS-9). pub const SlashDeferDuration: u32 = 0; } impl pallet_staking::Config for Runtime { // Balances: Currency es fungible hold-based; OldCurrency solo para migración. type Currency = Balances; type OldCurrency = Balances; type RuntimeHoldReason = RuntimeHoldReason; type CurrencyBalance = Balance; type UnixTime = Timestamp; type CurrencyToVote = sp_staking::currency_to_vote::U128CurrencyToVote; // Elección on-chain (dormida bajo ForceNone). DataProvider = la propia staking. type ElectionProvider = onchain::OnChainExecution; type GenesisElectionProvider = onchain::OnChainExecution; type VoterList = pallet_staking::UseNominatorsAndValidatorsMap; type TargetList = pallet_staking::UseValidatorsMap; type NominationsQuota = pallet_staking::FixedNominationsQuota<16>; // Emisión: migra a EraPayout (preserva la tasa de FIX-6 Stage 1). type EraPayout = OriginEraPayout; type RewardRemainder = DealWithEraRemainder; // Invulnerables nunca se slashan; rewards van por payout, no por estos hooks. type Slash = (); type Reward = (); type SessionsPerEra = SessionsPerEra; type BondingDuration = BondingDuration; type SlashDeferDuration = SlashDeferDuration; type AdminOrigin = EnsureRoot; // DORMIDO (Paso A): sin session-swap → staking no avanza eras. El swap a // `Self` + `Session` (que enciende EraPayout) es el siguiente paso. type SessionInterface = (); type NextNewSession = (); type MaxExposurePageSize = ConstU32<64>; type MaxValidatorSet = MaxValidatorSet; type MaxUnlockingChunks = ConstU32<32>; type MaxControllersInDeprecationBatch = ConstU32<100>; type HistoryDepth = ConstU32<84>; type EventListeners = (); type Filter = Nothing; type BenchmarkingConfig = OriginStakingBenchmarkingConfig; type WeightInfo = pallet_staking::weights::SubstrateWeight; type RuntimeEvent = RuntimeEvent; } // ─── Tests de la aritmética constitucional (verificar 70/20/10 ANTES de // activar NPoS en Paso C/D — hoy la emisión está dormida) ────────── #[cfg(test)] mod tests { use super::*; use pallet_staking::EraPayout as _; const ERA_24H_MILLIS: u64 = 24 * 60 * 60 * 1_000; // 86_400_000 #[test] fn era_payout_preserva_tasa_lineal_y_split_70_30() { // Era de 24h = 14_400 bloques de 6s. let (validator_payout, rest) = OriginEraPayout::era_payout(0, 0, ERA_24H_MILLIS); let blocks = (ERA_24H_MILLIS / MILLISECS_PER_BLOCK) as u128; assert_eq!(blocks, 14_400, "14_400 bloques en 24h a 6s"); let total = EmissionBlockReward::get().saturating_mul(blocks); // 70% validadores / 30% rest, y sin pérdida: validator + rest == total. assert_eq!(validator_payout, total * 7_000 / 10_000, "70% a validadores"); assert_eq!(validator_payout + rest, total, "cero pérdida: validator + rest == total"); // El rest es exactamente el 30% (con el dust del redondeo capturado). assert_eq!(rest, total - validator_payout); } #[test] fn era_payout_no_overflow_en_era_larga() { // Era de 1 año (peor caso plausible): no debe panic ni saturar mal. let one_year_millis: u64 = 365 * ERA_24H_MILLIS; let (validator_payout, rest) = OriginEraPayout::era_payout(0, 0, one_year_millis); let blocks = (one_year_millis / MILLISECS_PER_BLOCK) as u128; let total = EmissionBlockReward::get().saturating_mul(blocks); assert_eq!(validator_payout + rest, total, "sin overflow: invariante intacta"); // ~5.256M bloques/año × 10 OGC = ~5.256e25, lejos del techo u128. assert!(total < u128::MAX / 2); } #[test] fn remainder_split_20_10_del_total() { // El rest = 30% del total. Su split 2/3+1/3 = 20%+10% del total. // Ejemplo redondo: total=100 → rest=30 → treasury=20, subsidy=10. let (treasury, subsidy) = era_remainder_split(30); assert_eq!(treasury, 20, "2/3 del rest = 20% del total"); assert_eq!(subsidy, 10, "1/3 del rest = 10% del total"); assert_eq!(treasury + subsidy, 30, "cero pérdida"); } #[test] fn remainder_split_residual_al_subsidy_anti_clasista() { // Con rest no divisible por 3, el dust va al SUBSIDY (#16), no al treasury. let (treasury, subsidy) = era_remainder_split(31); assert_eq!(treasury, 20, "floor(31*2/3) = 20"); assert_eq!(subsidy, 11, "el residual (11) favorece el pool anti-clasista"); assert_eq!(treasury + subsidy, 31, "cero pérdida"); } #[test] fn remainder_split_invariante_para_montos_reales() { // Para un rest real (30% de una era de 24h) la invariante se mantiene. let (_, rest) = OriginEraPayout::era_payout(0, 0, ERA_24H_MILLIS); let (treasury, subsidy) = era_remainder_split(rest); assert_eq!(treasury + subsidy, rest, "cero pérdida en montos reales"); // treasury ≈ 2× subsidy (dust aparte). assert!(treasury >= subsidy, "treasury (20%) >= subsidy (10%)"); assert!(treasury <= subsidy.saturating_mul(2).saturating_add(2), "ratio ~2:1"); } }