Files
arkhe-origin-chain/crates/origin-chain-runtime/src/staking.rs
T
davidherranandClaude Opus 4.8 fcf768b046 test(npos): tests de la aritmética constitucional 70/20/10 (meta-audit)
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>
2026-06-16 17:36:25 -05:00

271 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # 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");
}
}