Meta-auditoría de mi propio trabajo (NPoS Paso A se shippeó en checkpoint sin verificación adversarial). Verificado contra el source de pallet-staking 43.0.0 que la infra es REALMENTE inerte (on_initialize=weight-only; on_finalize no-op con ActiveEra=None sin session) — cero regresión confirmada. Mejora aplicada: la aritmetica constitucional de OriginEraPayout + DealWithEraRemainder estaba escrita pero SIN tests (dormida, no se ejercita). Antes de encenderla en Paso C/D conviene probar el split: - era_remainder_split extraido a fn pura testeable (sin mock). - 5 tests: era_payout preserva tasa lineal + split 70/30 exacto + cero perdida; no overflow en era de 1 anio; remainder 20/10 del total; residual-al-subsidy (el dust del redondeo favorece el pool anti-clasista #16); invariante en montos reales. Sin bugs encontrados en Paso A. cargo +1.88. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
271 lines
12 KiB
Rust
271 lines
12 KiB
Rust
//! # 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<ChainAccountId, Balances>;
|
||
|
||
// ─── 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<Balance> 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<CreditOf> 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<T>` 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<true>;
|
||
type System = Runtime;
|
||
type Solver = SequentialPhragmen<ChainAccountId, Perbill>;
|
||
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<OnChainSeqPhragmen>;
|
||
type GenesisElectionProvider = onchain::OnChainExecution<OnChainSeqPhragmen>;
|
||
type VoterList = pallet_staking::UseNominatorsAndValidatorsMap<Runtime>;
|
||
type TargetList = pallet_staking::UseValidatorsMap<Runtime>;
|
||
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<ChainAccountId>;
|
||
|
||
// 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<Runtime>;
|
||
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");
|
||
}
|
||
}
|